Replace whole-structure golden == with same-process cached vs None fallback equality, plus discrete-strict / measured-tolerance golden comparison. Do not rebuild the golden JSON.
536 lines
20 KiB
Python
536 lines
20 KiB
Python
"""Memoization for candidate-minute invariants in the rectification engine.
|
||
|
||
Golden payload in tests/golden/rectification_engine_memoization_v1.json was
|
||
produced from origin/staging @ a8d29d1b before any memoization landed.
|
||
|
||
Do not compare that payload with a whole-structure ``==``. Cross-machine
|
||
libm / pyswisseph rounding already drifted ``margin_percent`` by 1.1e-3
|
||
(TASK-rectification-engine-memoization-fix-20260915). Equivalence of the
|
||
four cached layers is proven in-process instead.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import inspect
|
||
import json
|
||
import math
|
||
from datetime import date, datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from unittest.mock import patch
|
||
|
||
import pytest
|
||
|
||
from scripts.active_rectification_event_engine import (
|
||
_candidate_datetimes,
|
||
_controlled_transit_rules,
|
||
_shadbala_verified_components_auxiliary,
|
||
build_candidate_static_context,
|
||
compute_event_candidate_rows,
|
||
)
|
||
from scripts.rectification.api_service import score_candidates
|
||
from scripts.rectification.candidate_contrast import opportunity_from_probe
|
||
from scripts.rectification.contracts import normalize_rectification_request
|
||
from scripts.rectification.refinement_packet import build_refinement_packet
|
||
from scripts.rectification.scoring_service import sample_event_dates, scoreable_request
|
||
import scripts.active_rectification_event_engine as event_engine
|
||
import scripts.rectification.event_probes as event_probes
|
||
import scripts.rectification.scoring_service as scoring_service
|
||
import shadbala
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
GOLDEN_PATH = ROOT / "tests" / "golden" / "rectification_engine_memoization_v1.json"
|
||
FROZEN_TODAY = date(2026, 9, 16)
|
||
TIMING_KEYS = frozenset({"column_compare_ms"})
|
||
SOURCE_COMMIT = "a8d29d1b6cc37ff865ddec6c8bccdf9aa889ee53"
|
||
CACHE_LAYER_KEYS = (
|
||
"ashtakavarga_result",
|
||
"shadbala_result",
|
||
"vimshottari_timeline",
|
||
"narayana_periods",
|
||
)
|
||
# TASK-rectification-engine-memoization-fix-20260915 §2 measured max
|
||
# cross-machine drift of 1.1e-3 on decision_receipt.margin_percent
|
||
# (5.2995 vs golden 5.3006). Candidate scores, already rounded to 4
|
||
# decimals, drifted by 1 ulp (1.0e-4). Tolerance is the wider of
|
||
# abs=2e-3 and rel=5e-4 so 1.1e-3 still passes while a 1e-2 mutation
|
||
# (the reverse test below) fails.
|
||
GOLDEN_FLOAT_ABS = 2e-3
|
||
GOLDEN_FLOAT_REL = 5e-4
|
||
|
||
|
||
def public_score_request() -> dict[str, Any]:
|
||
"""Fictional events on the public 1990-01-01 Beijing smoke chart."""
|
||
return {
|
||
"birth_date": "1990-01-01",
|
||
"start_time": "12:00",
|
||
"end_time": "12:02",
|
||
"lat": 39.9,
|
||
"lon": 116.4,
|
||
"tz": 8,
|
||
"events": [
|
||
{
|
||
"id": "00000000-0000-4000-8000-000000000001",
|
||
"domain": "education",
|
||
"event_kind": "education_start",
|
||
"date_start": "2008-01-01",
|
||
"date_end": "2008-12-31",
|
||
"precision": "year",
|
||
"summary": "入学",
|
||
},
|
||
{
|
||
"id": "00000000-0000-4000-8000-000000000002",
|
||
"domain": "career",
|
||
"event_kind": "career_entry",
|
||
"date_start": "2012-06-01",
|
||
"date_end": "2012-06-30",
|
||
"precision": "month",
|
||
"summary": "入职",
|
||
},
|
||
],
|
||
}
|
||
|
||
|
||
def _strip_timing(value: Any) -> Any:
|
||
if isinstance(value, dict):
|
||
return {
|
||
key: _strip_timing(item)
|
||
for key, item in value.items()
|
||
if key not in TIMING_KEYS
|
||
}
|
||
if isinstance(value, list):
|
||
return [_strip_timing(item) for item in value]
|
||
return value
|
||
|
||
|
||
def _normalized_request() -> dict[str, Any]:
|
||
return normalize_rectification_request(public_score_request(), today=FROZEN_TODAY)
|
||
|
||
|
||
def _golden_payload() -> dict[str, Any]:
|
||
scored = score_candidates(_normalized_request())
|
||
return {
|
||
"source_commit": SOURCE_COMMIT,
|
||
"candidate_scores": scored["candidate_scores"],
|
||
"decision_receipt": _strip_timing(scored["decision_receipt"]),
|
||
"candidate_feature_snapshot": scored["candidate_feature_snapshot"],
|
||
}
|
||
|
||
|
||
def write_golden(path: Path = GOLDEN_PATH) -> Path:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(json.dumps(_golden_payload(), ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
|
||
return path
|
||
|
||
|
||
def _count_calls(monkeypatch, owner: Any, name: str, *, from_engine: bool = False) -> list[int]:
|
||
original = getattr(owner, name)
|
||
counter = [0]
|
||
|
||
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
||
if from_engine:
|
||
caller = inspect.stack()[1].filename.replace("\\", "/")
|
||
if not caller.endswith("/active_rectification_event_engine.py"):
|
||
return original(*args, **kwargs)
|
||
counter[0] += 1
|
||
return original(*args, **kwargs)
|
||
|
||
monkeypatch.setattr(owner, name, wrapped)
|
||
return counter
|
||
|
||
|
||
def _is_json_number(value: Any) -> bool:
|
||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||
|
||
|
||
def _assert_tiered_equal(actual: Any, expected: Any, *, path: str) -> None:
|
||
if isinstance(expected, dict):
|
||
assert isinstance(actual, dict), path
|
||
assert set(actual) == set(expected), f"{path} keys {set(actual)!r} != {set(expected)!r}"
|
||
for key in expected:
|
||
_assert_tiered_equal(actual[key], expected[key], path=f"{path}.{key}")
|
||
return
|
||
if isinstance(expected, list):
|
||
assert isinstance(actual, list), path
|
||
assert len(actual) == len(expected), f"{path} length {len(actual)} != {len(expected)}"
|
||
for index, (left, right) in enumerate(zip(actual, expected)):
|
||
_assert_tiered_equal(left, right, path=f"{path}[{index}]")
|
||
return
|
||
if isinstance(expected, int) and isinstance(actual, int) and not isinstance(expected, bool) and not isinstance(actual, bool):
|
||
assert actual == expected, f"{path}: {actual!r} != {expected!r}"
|
||
return
|
||
if _is_json_number(expected) or _is_json_number(actual):
|
||
left = float(actual)
|
||
right = float(expected)
|
||
tolerance = max(GOLDEN_FLOAT_ABS, GOLDEN_FLOAT_REL * abs(right))
|
||
assert math.isclose(left, right, rel_tol=0.0, abs_tol=tolerance), (
|
||
f"{path}: {left!r} vs {right!r} exceeds {tolerance}"
|
||
)
|
||
return
|
||
assert actual == expected, f"{path}: {actual!r} != {expected!r}"
|
||
|
||
|
||
def _assert_memoization_payloads(actual: dict[str, Any], expected: dict[str, Any]) -> None:
|
||
_assert_tiered_equal(actual["candidate_scores"], expected["candidate_scores"], path="candidate_scores")
|
||
_assert_tiered_equal(actual["decision_receipt"], expected["decision_receipt"], path="decision_receipt")
|
||
|
||
|
||
def _event_engine_request() -> dict[str, Any]:
|
||
"""Dated events for compute_event_candidate_rows (not the v5 score payload)."""
|
||
return {
|
||
"birth_date": "1990-01-01",
|
||
"start_time": "12:00",
|
||
"end_time": "12:02",
|
||
"lat": 39.9,
|
||
"lon": 116.4,
|
||
"tz": 8,
|
||
"events": [
|
||
{
|
||
"id": "00000000-0000-4000-8000-000000000001",
|
||
"domain": "education",
|
||
"event_kind": "education_start",
|
||
"date": "2008-07-01",
|
||
"precision": "day",
|
||
"summary": "入学",
|
||
},
|
||
{
|
||
"id": "00000000-0000-4000-8000-000000000002",
|
||
"domain": "career",
|
||
"event_kind": "career_entry",
|
||
"date": "2012-06-15",
|
||
"precision": "day",
|
||
"summary": "入职",
|
||
},
|
||
],
|
||
}
|
||
|
||
|
||
def _contexts_with_layer_cache_cleared(contexts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||
cleared: list[dict[str, Any]] = []
|
||
for context in contexts:
|
||
clone = dict(context)
|
||
for key in CACHE_LAYER_KEYS:
|
||
clone[key] = None
|
||
cleared.append(clone)
|
||
return cleared
|
||
|
||
|
||
def test_score_candidates_matches_baseline_golden() -> None:
|
||
expected = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
|
||
actual = json.loads(json.dumps(_golden_payload(), ensure_ascii=True))
|
||
_assert_memoization_payloads(actual, expected)
|
||
|
||
|
||
def test_golden_float_shift_of_1e_minus_2_fails() -> None:
|
||
expected = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
|
||
mutated = json.loads(json.dumps(expected))
|
||
mutated["candidate_scores"][0]["score"] += 1e-2
|
||
with pytest.raises(AssertionError):
|
||
_assert_memoization_payloads(expected, mutated)
|
||
|
||
|
||
def test_golden_discrete_field_mismatch_fails() -> None:
|
||
expected = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
|
||
mutated = json.loads(json.dumps(expected))
|
||
mutated["candidate_scores"][0]["time"] = "99:99"
|
||
with pytest.raises(AssertionError):
|
||
_assert_memoization_payloads(expected, mutated)
|
||
|
||
|
||
def test_shadbala_verified_fields_match_with_and_without_birth_minute() -> None:
|
||
request = {
|
||
"birth_date": "1990-01-01",
|
||
"start_time": "12:17",
|
||
"end_time": "12:17",
|
||
"lat": 39.9,
|
||
"lon": 116.4,
|
||
"tz": 8,
|
||
"events": [],
|
||
}
|
||
candidate_at = datetime(1990, 1, 1, 12, 17)
|
||
context = build_candidate_static_context(request, candidate_at)
|
||
chart = context["chart"]
|
||
planets = chart.get("planets", {})
|
||
sun = float(planets["Sun"]["lon"])
|
||
moon = float(planets["Moon"]["lon"])
|
||
sign = str(chart["ascendant"].get("sign"))
|
||
birth_hour = candidate_at.hour + candidate_at.minute / 60
|
||
with_minute = shadbala.calc_shadbala(
|
||
planets,
|
||
sign,
|
||
birth_hour,
|
||
sun,
|
||
moon,
|
||
birth_minute=float(candidate_at.minute),
|
||
)
|
||
without_minute = shadbala.calc_shadbala(planets, sign, birth_hour, sun, moon)
|
||
fields_with = {
|
||
planet: (
|
||
float((row.get("sthana_bala") or {}).get("total", 0)),
|
||
float(row.get("drik_bala", 0)),
|
||
float(row.get("naisargika_bala", 0)),
|
||
)
|
||
for planet, row in (with_minute.get("planets") or {}).items()
|
||
}
|
||
fields_without = {
|
||
planet: (
|
||
float((row.get("sthana_bala") or {}).get("total", 0)),
|
||
float(row.get("drik_bala", 0)),
|
||
float(row.get("naisargika_bala", 0)),
|
||
)
|
||
for planet, row in (without_minute.get("planets") or {}).items()
|
||
}
|
||
assert fields_with == fields_without
|
||
reused_rules, reused_points = _shadbala_verified_components_auxiliary(
|
||
chart, birth_hour, ("Sun", "Moon", "Mars"), shadbala_result=with_minute,
|
||
)
|
||
fresh_rules, fresh_points = _shadbala_verified_components_auxiliary(
|
||
chart, birth_hour, ("Sun", "Moon", "Mars"),
|
||
)
|
||
assert reused_rules == fresh_rules
|
||
assert reused_points == fresh_points
|
||
|
||
|
||
def test_shadbala_and_ashtakavarga_run_once_per_candidate(monkeypatch) -> None:
|
||
shadbala_calls = _count_calls(monkeypatch, event_engine.shadbala, "calc_shadbala")
|
||
ashtakavarga_calls = _count_calls(monkeypatch, event_engine.ashtakavarga, "calc_ashtakavarga")
|
||
request = _normalized_request()
|
||
scored = score_candidates(request)
|
||
candidate_count = len(scored["candidate_scores"])
|
||
assert candidate_count >= 2
|
||
assert len(request["events"]) >= 2
|
||
year_samples = sample_event_dates(request["events"][0])
|
||
assert request["events"][0]["precision"] == "year"
|
||
assert len(year_samples) >= 2
|
||
assert shadbala_calls[0] == candidate_count
|
||
assert ashtakavarga_calls[0] == candidate_count
|
||
|
||
|
||
def test_dasha_timelines_run_once_per_candidate(monkeypatch) -> None:
|
||
# Year-only events skip dasha-transition proximity, which also calls these
|
||
# two functions from event_probes. The scoring path itself must stay 1×/minute.
|
||
body = public_score_request()
|
||
body["events"][1]["precision"] = "year"
|
||
body["events"][1]["date_start"] = "2012-01-01"
|
||
body["events"][1]["date_end"] = "2012-12-31"
|
||
request = normalize_rectification_request(body, today=FROZEN_TODAY)
|
||
vim_calls = _count_calls(
|
||
monkeypatch, event_engine.dasha_analyzer, "build_dasha_timeline", from_engine=True,
|
||
)
|
||
narayana_calls = _count_calls(
|
||
monkeypatch, event_engine.narayana_dasha, "calc_narayana_mahadasha", from_engine=True,
|
||
)
|
||
scored = score_candidates(request)
|
||
candidate_count = len(scored["candidate_scores"])
|
||
assert len(request["events"]) >= 2
|
||
assert vim_calls[0] == candidate_count
|
||
assert narayana_calls[0] == candidate_count
|
||
|
||
|
||
def test_transit_charts_match_unique_event_dates_not_candidates(monkeypatch) -> None:
|
||
transit_calls = [0]
|
||
original = event_engine.domain_calculation_service.compute_chart
|
||
|
||
def wrapped(payload: dict[str, Any], *args: Any, **kwargs: Any) -> Any:
|
||
# Natal candidates in this fixture also sit at 12:00; transit charts use the event year.
|
||
if payload.get("hour") == 12 and payload.get("minute") == 0 and payload.get("year") != 1990:
|
||
transit_calls[0] += 1
|
||
return original(payload, *args, **kwargs)
|
||
|
||
monkeypatch.setattr(event_engine.domain_calculation_service, "compute_chart", wrapped)
|
||
request = _normalized_request()
|
||
scored = score_candidates(request)
|
||
candidate_count = len(scored["candidate_scores"])
|
||
expected_dates = {
|
||
sampled
|
||
for event in scoreable_request(request)["events"]
|
||
for sampled in sample_event_dates(event)
|
||
}
|
||
assert candidate_count >= 2
|
||
assert len(expected_dates) >= 2
|
||
assert transit_calls[0] == len(expected_dates)
|
||
assert transit_calls[0] != candidate_count * len(expected_dates)
|
||
|
||
|
||
def test_year_precision_transits_still_short_circuit() -> None:
|
||
request = {
|
||
"birth_date": "1990-01-01",
|
||
"start_time": "12:00",
|
||
"end_time": "12:00",
|
||
"lat": 39.9,
|
||
"lon": 116.4,
|
||
"tz": 8,
|
||
"events": [{
|
||
"id": "00000000-0000-4000-8000-000000000009",
|
||
"domain": "career",
|
||
"event_kind": "career_entry",
|
||
"date": "2012",
|
||
"precision": "year",
|
||
"summary": "入职",
|
||
}],
|
||
}
|
||
with patch.object(event_engine.domain_calculation_service, "compute_chart") as compute_chart:
|
||
rules = _controlled_transit_rules(request, request["events"][0], 0, (10,))
|
||
assert rules == []
|
||
compute_chart.assert_not_called()
|
||
|
||
|
||
def _probe_request_and_built() -> tuple[dict[str, Any], dict[str, Any], list[str]]:
|
||
body = public_score_request()
|
||
body["events"] = [
|
||
{
|
||
"id": f"00000000-0000-4000-8000-{index:012d}",
|
||
"domain": domain,
|
||
"event_kind": kind,
|
||
"date_start": "2012-01-01",
|
||
"date_end": "2012-12-31",
|
||
"precision": "year",
|
||
"summary": kind,
|
||
}
|
||
for index, (domain, kind) in enumerate(
|
||
(
|
||
("education", "education_start"),
|
||
("career", "career_entry"),
|
||
("relationship", "relationship_start"),
|
||
),
|
||
start=1,
|
||
)
|
||
]
|
||
request = normalize_rectification_request(body, today=FROZEN_TODAY)
|
||
times = ["12:00", "12:01", "12:02"]
|
||
event_ids = [event["id"] for event in request["events"]]
|
||
built = {
|
||
"candidate_times": times,
|
||
"matrix": {
|
||
event_id: {
|
||
clock: {"points": 4, "rule_ids": ["vim_md_domain_house"], "technique_layers": ["vim_md_domain_house"]}
|
||
for clock in times
|
||
}
|
||
for event_id in event_ids
|
||
},
|
||
"date_sensitivity": [],
|
||
"missing_layers": [],
|
||
"static_contexts": [],
|
||
}
|
||
return request, built, times
|
||
|
||
|
||
def test_discriminating_probes_run_once_when_probe_times_equal_grid(monkeypatch) -> None:
|
||
calls = _count_calls(monkeypatch, event_probes, "_discriminating_event_probe_lists")
|
||
request, built, times = _probe_request_and_built()
|
||
packet = build_refinement_packet(
|
||
request,
|
||
built,
|
||
representative_time="12:00",
|
||
candidate_times=times,
|
||
)
|
||
assert calls[0] == 1
|
||
assert packet["candidate_contrast_opportunities"] == [
|
||
opportunity_from_probe(probe) for probe in packet["discriminating_event_probes"]
|
||
]
|
||
|
||
|
||
def test_discriminating_probes_run_twice_on_refresh_columns(monkeypatch) -> None:
|
||
calls = _count_calls(monkeypatch, event_probes, "_discriminating_event_probe_lists")
|
||
request, built, times = _probe_request_and_built()
|
||
request = {**request, "refresh_probes": True}
|
||
packet = build_refinement_packet(
|
||
request,
|
||
built,
|
||
representative_time="12:00",
|
||
candidate_times=times,
|
||
column_times=["12:00"],
|
||
)
|
||
assert times != ["12:00"]
|
||
assert calls[0] == 2
|
||
assert "discriminating_event_probes" in packet
|
||
assert "candidate_contrast_opportunities" in packet
|
||
|
||
|
||
def test_dead_row_cache_removed_from_scoring_service() -> None:
|
||
token = "_cached_" + "rows"
|
||
assert not hasattr(scoring_service, token)
|
||
hits: list[str] = []
|
||
for folder in ("scripts", "tests"):
|
||
for path in (ROOT / folder).rglob("*.py"):
|
||
text = path.read_text(encoding="utf-8")
|
||
if token in text:
|
||
hits.append(str(path.relative_to(ROOT)).replace("\\", "/"))
|
||
assert hits == []
|
||
|
||
|
||
def test_compute_event_candidate_rows_reuses_static_context_without_mutating_it() -> None:
|
||
request = {
|
||
"birth_date": "1990-01-01",
|
||
"start_time": "12:00",
|
||
"end_time": "12:01",
|
||
"lat": 39.9,
|
||
"lon": 116.4,
|
||
"tz": 8,
|
||
"events": [{
|
||
"id": "00000000-0000-4000-8000-000000000003",
|
||
"domain": "career",
|
||
"event_kind": "career_entry",
|
||
"date": "2012-06-15",
|
||
"precision": "day",
|
||
"summary": "入职",
|
||
}],
|
||
}
|
||
contexts = [
|
||
build_candidate_static_context(request, candidate)
|
||
for candidate in _candidate_datetimes(request)
|
||
]
|
||
original_keys = [frozenset(context) for context in contexts]
|
||
rows = compute_event_candidate_rows(request, static_contexts=contexts)
|
||
assert len(rows) == 2
|
||
assert [frozenset(context) for context in contexts] == original_keys
|
||
assert all("_transit_chart_cache" not in context for context in contexts)
|
||
|
||
|
||
def test_cached_static_context_matches_uncached_fallback(monkeypatch) -> None:
|
||
request = _event_engine_request()
|
||
cached = [
|
||
build_candidate_static_context(request, candidate)
|
||
for candidate in _candidate_datetimes(request)
|
||
]
|
||
assert len(cached) >= 2
|
||
assert len(request["events"]) >= 2
|
||
for context in cached:
|
||
for key in CACHE_LAYER_KEYS:
|
||
assert context.get(key) is not None
|
||
uncached = _contexts_with_layer_cache_cleared(cached)
|
||
for context in uncached:
|
||
for key in CACHE_LAYER_KEYS:
|
||
assert context[key] is None
|
||
calls = _count_calls(monkeypatch, event_engine.shadbala, "calc_shadbala")
|
||
rows_cached = compute_event_candidate_rows(request, static_contexts=cached)
|
||
cached_calls = calls[0]
|
||
rows_uncached = compute_event_candidate_rows(request, static_contexts=uncached)
|
||
uncached_calls = calls[0] - cached_calls
|
||
assert rows_cached == rows_uncached
|
||
assert cached_calls == 0
|
||
assert uncached_calls > cached_calls
|
||
assert uncached_calls >= len(cached) * len(request["events"])
|
||
|
||
|
||
def test_poisoned_static_cache_diverges_from_live_rows() -> None:
|
||
request = _event_engine_request()
|
||
cached = [
|
||
build_candidate_static_context(request, candidate)
|
||
for candidate in _candidate_datetimes(request)
|
||
]
|
||
rows_cached = compute_event_candidate_rows(request, static_contexts=cached)
|
||
poisoned = [dict(context) for context in cached]
|
||
poisoned[0]["shadbala_result"] = {
|
||
"planets": {
|
||
name: {
|
||
"sthana_bala": {"total": 0.0},
|
||
"drik_bala": 0.0,
|
||
"naisargika_bala": 0.0,
|
||
}
|
||
for name in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
|
||
}
|
||
}
|
||
rows_poisoned = compute_event_candidate_rows(request, static_contexts=poisoned)
|
||
assert rows_poisoned != rows_cached
|