Files
Jyotisha/tests/test_dasha_transition_proximity_cross_midnight.py
T
jesse-ux e5f2a75183 fix(ephemeris): include nutation in the sidereal frame
恒星黄道改为与 Swiss Ephemeris FLG_SIDEREAL 同一套含章动岁差。
产品默认路径不再用平岁差去减含章动的视位置。上升和宫头用同一个岁差。
校正身份升到 rectification-v5-matrix-scoring-12。
BUG-1275。未推送,未部署。v5 先验头名三格下降 1.3 个百分点,不调参。
2026-10-08 18:42:45 +08:00

191 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Candidate-local dasha dates; public AA replay and synthetic cache boundaries."""
from __future__ import annotations
import json
from datetime import date, datetime, timedelta
import pytest
from scripts.active_rectification_event_engine import compute_candidate_static_contexts
from scripts.rectification import dasha_transition_proximity as proximity
from scripts.rectification.scoring_service import (
build_event_contribution_matrix,
public_technique_layers,
score_from_matrix,
)
from scripts.research.reported_offset_sweep import shifted_window
from scripts.research.sealed_holdout_rerun import DATASET
def _canonical(value):
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
def _cases():
return json.loads(DATASET.read_text(encoding="utf-8"))["cases"]
def test_fixed_scoring_identity_is_exposed_without_changing_input_contract():
from scripts.rectification.api_service import engine_scoring_versions
from scripts.rectification.scoring_service import (
ALGORITHM_VERSION, INPUT_CONTRACT_VERSION, calculation_spec, sha256,
)
# 原值: "rectification-v5-matrix-scoring-11";新值: "...-scoring-12";原因: 恒星黄道补上章动后同输入分数变了
# (BUG-1275),按 BUG-1181 升算法身份;输入合同 v5 与日期窗合同不变。
assert engine_scoring_versions()["algorithm_version"] == ALGORITHM_VERSION == "rectification-v5-matrix-scoring-12"
assert INPUT_CONTRACT_VERSION == "rectification-calculation-spec-v5"
request = {"birth_date": "2000-03-01", "start_time": "00:00", "end_time": "00:02",
"lat": 0.0, "lon": 0.0, "tz": 0.0, "events": []}
legacy_spec = calculation_spec(request)
assert legacy_spec["version"] == "rectification-calculation-spec-v4"
# Computed from unchanged baseline 3be740f8, not from the helper under test.
assert sha256(legacy_spec) == "bfdee46405a38f2508a2071929b60d79d15a78ca0e1e81f6c7ffd1652e301a08"
dated_spec = calculation_spec({**request, "candidate_intervals": [
{"start_at": "2000-03-01T00:00", "end_at": "2000-03-01T00:02"},
]})
assert dated_spec == {**legacy_spec, "version": "rectification-calculation-spec-v5",
"candidateIntervals": [{"start_at": "2000-03-01T00:00", "end_at": "2000-03-01T00:02"}]}
assert sha256(dated_spec) != sha256(legacy_spec)
@pytest.mark.parametrize("start", ["2000-01-01", "2000-02-29", "2000-12-31"])
def test_every_candidate_uses_own_date_and_caches_do_not_cross_dates(monkeypatch, start):
# Intentionally identical synthetic chart values: only date distinguishes caches.
anchor = date.fromisoformat(start)
moments = [datetime.combine(anchor, datetime.min.time()) + timedelta(hours=23, minutes=50+i)
for i in range(21)]
contexts = [{"candidate_at": at, "feature": {"time": at.strftime("%H:%M")},
"planet_longitudes": {"Moon": 42.0}, "ascendant_index": 1}
for at in moments]
event_date = date(2020, 1, 10)
calls = {"ad": [], "pd": [], "narayana": []}
def vim(birth_date, moon, lo, hi, *, include_pratyantar=False):
calls["pd" if include_pratyantar else "ad"].append(birth_date)
delta = (date.fromisoformat(birth_date) - anchor).days
return [event_date + timedelta(days=delta + (3 if include_pratyantar else 2))]
def narayana(asc, planets, birth_date, lo, hi):
calls["narayana"].append(birth_date)
delta = (date.fromisoformat(birth_date) - anchor).days
return [event_date + timedelta(days=delta + 4)]
monkeypatch.setattr(proximity, "_vim_start_dates", vim)
monkeypatch.setattr(proximity, "_narayana_start_dates", narayana)
# Two events sharing the year band exercise cache reuse, not merely a new call.
events = [{"id": precision, "domain": "career", "precision": precision,
"date_start": event_date.isoformat(), "date_end": event_date.isoformat()}
for precision in ("day", "month")]
matrix = {event["id"]: {at.strftime("%H:%M"): {"points": 2.0, "rule_ids": []}
for at in moments} for event in events}
proximity.merge_transition_proximity(matrix, events, contexts, start,
public_technique_layers=public_technique_layers)
for at in moments:
delta = (at.date() - anchor).days
for precision in ("day", "month"):
expected = proximity.score_transition_proximity(
event_date=event_date, precision=precision,
vim_starts=[event_date + timedelta(days=delta + 2)],
vim_pd_starts=[event_date + timedelta(days=delta + 3)],
narayana_starts=[event_date + timedelta(days=delta + 4)],
)
actual = matrix[precision][at.strftime("%H:%M")]
assert actual["points"] == round(2.0 + expected["points"], 4)
assert actual["rule_ids"] == sorted(expected["rule_ids"])
expected_dates = [anchor.isoformat(), (anchor + timedelta(days=1)).isoformat()]
assert calls == {kind: expected_dates for kind in calls}
def test_legacy_context_without_candidate_at_retains_request_date(monkeypatch):
calls = []
def vim(birth_date, moon, lo, hi, **kwargs):
calls.append(birth_date)
return []
monkeypatch.setattr(proximity, "_vim_start_dates", vim)
monkeypatch.setattr(proximity, "_narayana_start_dates", lambda *args: [])
proximity.merge_transition_proximity(
{"event": {"12:00": {"points": 2.0}}},
[{"id": "event", "precision": "day", "date": "2020-01-10"}],
[{"feature": {"time": "12:00"}, "planet_longitudes": {"Moon": 42.0}}],
"2000-01-01", public_technique_layers=public_technique_layers,
)
assert calls == ["2000-01-01", "2000-01-01"]
@pytest.mark.parametrize("ordinal", [1, 2, 3])
def test_same_day_public_aa_scores_keep_pre_fix_bytes(monkeypatch, ordinal):
from scripts.rectification import scoring_service
# BUG-985 / BUG-733: equivalence is same-process, not a cross-machine float golden.
request, moments = shifted_window(_cases()[ordinal - 1], 0, 60)
assert {moment.date().isoformat() for moment in moments} == {request["birth_date"]}
contexts = compute_candidate_static_contexts(request)
assert [context["candidate_at"] for context in contexts] == moments
built = build_event_contribution_matrix(request, static_contexts=contexts)
scores = [row["score"] for row in score_from_matrix(request, built)]
legacy_calls = []
def legacy_merge(matrix, events, static_contexts, birth_date, **kwargs):
# Strip only at the helper boundary; the main event engine needs candidate_at.
assert static_contexts is contexts
legacy_contexts = [
{key: value for key, value in context.items() if key != "candidate_at"}
for context in static_contexts
]
legacy_calls.append(len(legacy_contexts))
return proximity.merge_transition_proximity(
matrix, events, legacy_contexts, birth_date, **kwargs,
)
with monkeypatch.context() as legacy:
legacy.setattr(scoring_service, "merge_transition_proximity", legacy_merge)
legacy_built = build_event_contribution_matrix(request, static_contexts=contexts)
legacy_scores = [row["score"] for row in score_from_matrix(request, legacy_built)]
assert legacy_calls == [121]
assert len(scores) == len(legacy_scores) == 121
assert _canonical(scores) == _canonical(legacy_scores)
assert _canonical(built["matrix"]) == _canonical(legacy_built["matrix"])
def test_real_cross_midnight_all_candidates_match_independent_dated_calculation(monkeypatch):
# Existing public AA case naturally crosses midnight at radius 60; no birth mutation.
request, moments = shifted_window(_cases()[5], 0, 60)
contexts = compute_candidate_static_contexts(request)
assert [context["candidate_at"] for context in contexts] == moments
assert len({moment.date() for moment in moments}) == 2
original_vim, original_narayana = proximity._vim_start_dates, proximity._narayana_start_dates
seen_vim, seen_narayana = set(), set()
def vim(birth_date, moon, lo, hi, *, include_pratyantar=False):
seen_vim.add((birth_date, round(moon, 6), include_pratyantar))
return original_vim(birth_date, moon, lo, hi, include_pratyantar=include_pratyantar)
def narayana(asc, planets, birth_date, lo, hi):
seen_narayana.add((birth_date, asc, round(planets["Moon"], 6)))
return original_narayana(asc, planets, birth_date, lo, hi)
with monkeypatch.context() as capture:
capture.setattr(proximity, "_vim_start_dates", vim)
capture.setattr(proximity, "_narayana_start_dates", narayana)
actual = build_event_contribution_matrix(request, static_contexts=contexts)
expected_rows, expected_matrix = [], {}
for context in contexts:
candidate_date = context["candidate_at"].date().isoformat()
dated_request = {**request, "birth_date": candidate_date}
# One correctly dated candidate per independent matrix, with fresh local caches.
built = build_event_contribution_matrix(dated_request, static_contexts=[context])
expected_rows.extend(score_from_matrix(dated_request, built))
for event_id, cells in built["matrix"].items():
expected_matrix.setdefault(event_id, {}).update(cells)
assert _canonical(actual["matrix"]) == _canonical(expected_matrix)
assert _canonical(score_from_matrix(request, actual)) == _canonical(expected_rows)
for context in contexts:
candidate_date = context["candidate_at"].date().isoformat()
moon = round(context["planet_longitudes"]["Moon"], 6)
assert (candidate_date, moon, False) in seen_vim
assert (candidate_date, moon, True) in seen_vim
assert (candidate_date, context["ascendant_index"], moon) in seen_narayana