test(rectification): prove memoization in-process; tolerate golden float drift (BUG-733)
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.
This commit is contained in:
@@ -2,17 +2,25 @@
|
||||
|
||||
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,
|
||||
@@ -35,6 +43,20 @@ GOLDEN_PATH = ROOT / "tests" / "golden" / "rectification_engine_memoization_v1.j
|
||||
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]:
|
||||
@@ -117,11 +139,102 @@ def _count_calls(monkeypatch, owner: Any, name: str, *, from_engine: bool = Fals
|
||||
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 actual["candidate_scores"] == expected["candidate_scores"]
|
||||
assert actual["decision_receipt"] == expected["decision_receipt"]
|
||||
_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:
|
||||
@@ -372,3 +485,51 @@ def test_compute_event_candidate_rows_reuses_static_context_without_mutating_it(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user