Files
Jyotisha/tests/test_dasha_transition_proximity_cross_midnight.py
T
Jesse_ChenandClaude Opus 5.5 3733b9787b fix(rectification): bump scoring identity to scoring-10 for functional profile v2; dated contract by generation (BUG-1181)
Functional roles feed the *_functional_*_auxiliary rules, so 57782aea changes
candidate scores for identical input (memoization fixture 12:00: 8.6274 ->
8.4977). Per the "scoring semantics change => bump ALGORITHM_VERSION"
precedent (scoring-7 -> 8 -> 9), the identity moves to scoring-10; policy v3,
input contract v5 and Skill versions are unchanged, history is not relabeled.

Five frontend sites and one SQL guard tested `=== "...scoring-9"` for the
dated candidate-window contract; they now use isDatedScoringAlgorithmVersion /
a generation regex (>= 9). Migration 20261002010000 only recreates
validate_dated_rectification_candidate (one-line guard change).

Memoization golden v2 written by the test's own write_golden; v1 (scoring-8)
frozen by sha256. Real-engine scoring-10 cross-midnight golden added. Research
records re-frozen per ERR-110 (label functional_v2_2026_10_02) and
scripts/functional_benefics.py added to the frozen production identity
(ERR-114: 57782aea changed scores without tripping it).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-02 12:31:50 +08:00

191 lines
9.8 KiB
Python

"""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-9";新值: "...-scoring-10";原因: 功能吉凶 v2 改变同输入分数,
# 按「打分语义变化必须 bump」先例升算法身份(BUG-1181);输入合同 v5 与日期窗合同不变。
assert engine_scoring_versions()["algorithm_version"] == ALGORITHM_VERSION == "rectification-v5-matrix-scoring-10"
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