fix(rectification): 候选分钟不变量记忆化(BUG-721)
Shadbala / Ashtakavarga / Dasha 时间轴进 static context;过境盘按事件日期缓存 chart;探针网格相同时只算一次。打分与决策回执与基线 golden 逐字相同(剔除计时字段)。
This commit is contained in:
+16
-1
@@ -11206,6 +11206,22 @@
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-721 | 生时校正把候选分钟不变量放在事件循环里重复计算
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-15
|
||||
- 最近更新:2026-09-16
|
||||
- 影响面:`scripts/active_rectification_event_engine.py`、`scripts/rectification/scoring_service.py`、`scripts/rectification/refinement_packet.py`
|
||||
- 用户现象:每记一条证据或答一道题都要重算 `POST /api/rectification/v5/score`,等待明显偏长。不是回归,也不是结果算错。
|
||||
- 触发条件:一次请求里有多个候选分钟,并且事件带 year/month 采样(采样日把候选×事件再放大)。
|
||||
- 根因:`build_candidate_static_context` 引入后,排盘/分盘按候选分钟只算一次,但 Shadbala、Ashtakavarga、Vimshottari 时间轴、Narayana 周期表仍留在 `_candidate_row` 的事件循环里,被「候选 × 事件 × 采样日」三重放大。过境盘只依赖事件日期,却按候选分钟在最内层重算。`build_refinement_packet` 在 `probe_times == grid_times` 时对 `_discriminating_event_probe_lists` 算两遍。`scoring_service._cached_rows` 是加错层的死代码,生产入口从不走。
|
||||
- 修复:把候选分钟不变量挂进 static context,过境盘在 `compute_event_candidate_rows` 调用栈内用局部字典按日期缓存 chart(规则判定仍按候选算),探针在时间网格相同时复用一次结果;删掉 `_cached_rows`。不改算法、权重、阈值、采样规则,也不修 static context 里 Shadbala 的 `birth_minute` 双算。
|
||||
- 验证:基线 `a8d29d1b` 真实跑出 golden(公开 1990-01-01 北京烟测盘 + 虚构事件);改后 `candidate_scores` 与剔除 `column_compare_ms` 的 `decision_receipt` 逐字相同。计数断言:`calc_shadbala` / `calc_ashtakavarga` / `build_dasha_timeline` / `calc_narayana_mahadasha` 在引擎打分路径上各等于候选分钟数;过境 `compute_chart` 等于去重事件日期数;year 精度过境仍早退 `[]`;默认探针路径 1 次、`refresh_probes` 且 refresh 列存在时 2 次。
|
||||
- 防复发:新增的候选分钟不变量必须进 static context,不得留在 `_candidate_row` 的事件循环里;新增的事件不变量不得按候选迭代。记忆化只允许请求内显式传递的 context / 局部字典,禁止模块级 `lru_cache` 跨请求持有出生资料派生数据。
|
||||
- 相关记录:无
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-722 | 意图分类器两次异常被说成用户没说清,经历被丢掉
|
||||
|
||||
- 状态:resolved
|
||||
@@ -11253,4 +11269,3 @@
|
||||
- 相关记录:BUG-059、BUG-388
|
||||
- 复发自:BUG-059(总预算约束);BUG-388 的防复发只写了单次尝试,因此没拦住
|
||||
- 修复版本:待发布
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# PROGRESS · 重算记忆化(2026-09-15 / 执行 2026-09-16)
|
||||
|
||||
- 执行分支:`codex/rectification-engine-memoization-20260915`
|
||||
- 工作树:`.worktrees/rectification-engine-memoization-20260915`
|
||||
- 任务书基线:`6b3248bf`(已被后续合入)。本机开工 `HEAD` = `origin/staging` = `a8d29d1b`
|
||||
- BUG:`BUG-721`(落库时最大号仍是 720,未顺延)
|
||||
|
||||
## 开工前置
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `git status -sb` 第一行 | `## codex/rectification-engine-memoization-20260915...origin/staging` |
|
||||
| `HEAD` | `a8d29d1b` |
|
||||
| BUG_HISTORY 检索 | `重复计算` / `shadbala` / `scoring_service` / `_cached_rows`:无同模块分层遗漏记录。最大号 BUG-720 |
|
||||
| 本机 Python | 主仓 `.venv` 是 25 字节指针。本 worktree 用 Anaconda 3.11.7 `--system-site-packages` 建真实 venv,复用已有 `pyswisseph 2.10.3.2` / swisseph `20230604`,未升级该包 |
|
||||
| 基线 golden | 改代码前用 `a8d29d1b` 真实跑出 `tests/golden/rectification_engine_memoization_v1.json`。golden 测试改前绿 |
|
||||
|
||||
未改 `docs/tasks/README.md` 状态列。未推 staging。未改 `jyotish_api_server.py`、前端、`sample_event_dates`、`_SWISSEPH_LOCK`。
|
||||
|
||||
## 做了什么
|
||||
|
||||
6.1–6.6 全做,未按 §8 让步砍项。
|
||||
|
||||
| 条 | 做法 |
|
||||
| --- | --- |
|
||||
| 6.1 | `build_candidate_static_context` 把已算的 `ashtakavarga_result` / `shadbala_result` 挂进 context。辅助函数只读结果做判定。Shadbala 只复用 `sthana_bala.total` / `drik_bala` / `naisargika_bala`;测试证明带/不带 `birth_minute` 这三项相等。`birth_minute` 双算不修(§4.3) |
|
||||
| 6.2 | Vimshottari 时间轴、Narayana 周期表进 context;`_active_vimshottari` / `_active_narayana` 只留 `find_current` / `get_current_narayana_dasha` |
|
||||
| 6.3 | `compute_event_candidate_rows` 内局部字典按事件日期缓存过境 **chart**;规则仍按候选算。`precision == "year"` 仍早退 `[]` |
|
||||
| 6.4 | `probe_times == grid_times` 时 `discriminating_event_probe_set` 结果复用给 contrast;`refresh_probes` 且 refresh 列存在时仍两次 |
|
||||
| 6.5 | 删除 `scoring_service._cached_rows`。`_canonical` 仍被 `sha256` 使用,保留 |
|
||||
| 6.6 | 公开 1990-01-01 北京烟测盘 + 虚构事件。golden 由基线代码跑出,禁止手造 |
|
||||
|
||||
记忆化只在请求内:static context dict,以及 `compute_event_candidate_rows` / `build_refinement_packet` 栈内局部字典。无模块级跨请求缓存。
|
||||
|
||||
## 测试
|
||||
|
||||
定向(任务书 §9 + 本单新文件):
|
||||
|
||||
| 套件 | 结果 |
|
||||
| --- | --- |
|
||||
| `tests/test_rectification_engine_memoization.py` | **10 passed** |
|
||||
| `test_rectification_v5_services.py` 等 7 个任务书名单 | 与本单文件合计 **129 passed** |
|
||||
| quality-gate quick 的 pytest(含 `tests/test_rectification_*.py`) | **776 passed, 1 skipped**(575.78s) |
|
||||
|
||||
本机 `run_quality_gate.py --profile quick` 在 pytest 之后的 `npm test` 失败:`tsx` 不在 PATH(Windows 前端环境缺口)。本单未改前端,不把这条写成通过。
|
||||
|
||||
未改任何既有断言的期望值。测试总数高于开工时(新增 10 条)。
|
||||
|
||||
## CHANGELOG
|
||||
|
||||
未改。纯内部记忆化,用户可见打分与决策回执逐字不变(计时字段除外)。
|
||||
|
||||
## 偏离
|
||||
|
||||
- 任务书写 `.venv/bin/python`,本机换成 `.\.venv\Scripts\python.exe`。
|
||||
- golden 测试只锁 `candidate_scores` 与剔除 `column_compare_ms` 的 `decision_receipt`(6.6 原文)。`candidate_feature_snapshot` 写进 golden 文件备查,不作为 pytest 硬断言:KP/Hora 指纹在 pytest 全量收集下会出现第二条稳定哈希,与打分无关。
|
||||
- 未 `git push origin HEAD:staging`。
|
||||
|
||||
## 观察项(不修)
|
||||
|
||||
`build_candidate_static_context` 的 Shadbala `birth_hour = hour + minute/60` 同时又传 `birth_minute`,只流进 fingerprints,本单照搬。
|
||||
@@ -107,13 +107,15 @@ def _active_vimshottari(
|
||||
birth_date: str,
|
||||
moon_longitude: float,
|
||||
event_at: datetime,
|
||||
timeline: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[str, str, str]:
|
||||
nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(moon_longitude)
|
||||
timeline, _, _, _ = dasha_analyzer.build_dasha_timeline(
|
||||
birth_date,
|
||||
nakshatra,
|
||||
progress,
|
||||
)
|
||||
if timeline is None:
|
||||
nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(moon_longitude)
|
||||
timeline, _, _, _ = dasha_analyzer.build_dasha_timeline(
|
||||
birth_date,
|
||||
nakshatra,
|
||||
progress,
|
||||
)
|
||||
_, major = dasha_analyzer.find_current(timeline, event_at)
|
||||
minor = dasha_analyzer.find_current_sub(
|
||||
dasha_analyzer.build_antardasha(major),
|
||||
@@ -131,11 +133,13 @@ def _active_narayana(
|
||||
planet_longitudes: dict[str, float],
|
||||
birth_at: datetime,
|
||||
event_at: datetime,
|
||||
periods: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[int | None, int | None]:
|
||||
periods = narayana_dasha.calc_narayana_mahadasha(
|
||||
ascendant_index,
|
||||
planet_longitudes,
|
||||
)
|
||||
if periods is None:
|
||||
periods = narayana_dasha.calc_narayana_mahadasha(
|
||||
ascendant_index,
|
||||
planet_longitudes,
|
||||
)
|
||||
age = max((event_at - birth_at).total_seconds() / (365.2425 * 86_400), 0.0)
|
||||
active = narayana_dasha.get_current_narayana_dasha(periods, age)
|
||||
major = active.get("md") or {}
|
||||
@@ -280,23 +284,45 @@ def _score_event(
|
||||
}
|
||||
|
||||
|
||||
def _transit_chart_cache_key(
|
||||
request: RectificationEventRequest,
|
||||
event_at: datetime,
|
||||
) -> tuple[Any, ...]:
|
||||
return (
|
||||
event_at.date().isoformat(),
|
||||
float(request["lat"]),
|
||||
float(request["lon"]),
|
||||
float(request["tz"]),
|
||||
request.get("ayanamsa", AYANAMSA),
|
||||
request.get("node_mode", NODE_MODE),
|
||||
)
|
||||
|
||||
|
||||
def _controlled_transit_rules(
|
||||
request: RectificationEventRequest,
|
||||
event: LifeEvent,
|
||||
natal_ascendant_index: int,
|
||||
target_houses: tuple[int, ...],
|
||||
transit_chart_cache: dict[tuple[Any, ...], dict[str, Any]] | None = None,
|
||||
) -> list[str]:
|
||||
"""Use only Jupiter/Saturn and only day/month dated events as a weak check."""
|
||||
if event["precision"] == "year":
|
||||
return []
|
||||
event_at = _event_datetime(event)
|
||||
transit_chart = domain_calculation_service.compute_chart({
|
||||
payload = {
|
||||
"year": event_at.year, "month": event_at.month, "day": event_at.day,
|
||||
"hour": 12, "minute": 0, "lat": request["lat"], "lon": request["lon"],
|
||||
"tz": request["tz"],
|
||||
"ayanamsa": request.get("ayanamsa", AYANAMSA),
|
||||
"node_mode": request.get("node_mode", NODE_MODE),
|
||||
})
|
||||
}
|
||||
cache_key = _transit_chart_cache_key(request, event_at)
|
||||
if transit_chart_cache is not None and cache_key in transit_chart_cache:
|
||||
transit_chart = transit_chart_cache[cache_key]
|
||||
else:
|
||||
transit_chart = domain_calculation_service.compute_chart(payload)
|
||||
if transit_chart_cache is not None:
|
||||
transit_chart_cache[cache_key] = transit_chart
|
||||
rules: list[str] = []
|
||||
for planet in ("Jupiter", "Saturn"):
|
||||
item = transit_chart.get("planets", {}).get(planet) or {}
|
||||
@@ -305,9 +331,16 @@ def _controlled_transit_rules(
|
||||
return rules
|
||||
|
||||
|
||||
def _ashtakavarga_auxiliary(natal_chart: dict, ascendant_index: int, target_houses: tuple[int, ...]) -> tuple[list[str], float]:
|
||||
def _ashtakavarga_auxiliary(
|
||||
natal_chart: dict,
|
||||
ascendant_index: int,
|
||||
target_houses: tuple[int, ...],
|
||||
ashtakavarga_result: dict[str, Any] | None = None,
|
||||
) -> tuple[list[str], float]:
|
||||
"""Return a bounded SAV consistency adjustment, never a standalone trigger."""
|
||||
result = ashtakavarga.calc_ashtakavarga(natal_chart.get("planets", {}), ascendant_index)
|
||||
result = ashtakavarga_result
|
||||
if result is None:
|
||||
result = ashtakavarga.calc_ashtakavarga(natal_chart.get("planets", {}), ascendant_index)
|
||||
if not result.get("all_bav_valid") or not (result.get("sav") or {}).get("valid"):
|
||||
return [], 0.0
|
||||
house_scores = result.get("house_scores_full") or {}
|
||||
@@ -323,17 +356,24 @@ def _ashtakavarga_auxiliary(natal_chart: dict, ascendant_index: int, target_hous
|
||||
return [], 0.0
|
||||
|
||||
|
||||
def _shadbala_verified_components_auxiliary(natal_chart: dict, birth_hour: float, dasha_lords: tuple[str, str, str]) -> tuple[list[str], float]:
|
||||
def _shadbala_verified_components_auxiliary(
|
||||
natal_chart: dict,
|
||||
birth_hour: float,
|
||||
dasha_lords: tuple[str, str, str],
|
||||
shadbala_result: dict[str, Any] | None = None,
|
||||
) -> tuple[list[str], float]:
|
||||
"""Use only Sthana/Drik/Naisargika, whose oracle comparison is already matched."""
|
||||
planets = natal_chart.get("planets", {})
|
||||
sun = planets.get("Sun") or {}
|
||||
moon = planets.get("Moon") or {}
|
||||
if not isinstance(sun.get("lon"), (int, float)) or not isinstance(moon.get("lon"), (int, float)):
|
||||
return [], 0.0
|
||||
result = shadbala.calc_shadbala(
|
||||
planets, str(natal_chart["ascendant"].get("sign") or "Aries"), birth_hour,
|
||||
float(sun["lon"]), float(moon["lon"]),
|
||||
)
|
||||
result = shadbala_result
|
||||
if result is None:
|
||||
result = shadbala.calc_shadbala(
|
||||
planets, str(natal_chart["ascendant"].get("sign") or "Aries"), birth_hour,
|
||||
float(sun["lon"]), float(moon["lon"]),
|
||||
)
|
||||
values = {
|
||||
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 (result.get("planets") or {}).items()
|
||||
@@ -490,6 +530,26 @@ def build_candidate_static_context(
|
||||
except (KeyError, TypeError, ValueError):
|
||||
blocked_layers.append("Shadbala")
|
||||
|
||||
vimshottari_timeline = None
|
||||
try:
|
||||
nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(planet_longitudes["Moon"])
|
||||
vimshottari_timeline, _, _, _ = dasha_analyzer.build_dasha_timeline(
|
||||
candidate_at.date().isoformat(),
|
||||
nakshatra,
|
||||
progress,
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
vimshottari_timeline = None
|
||||
|
||||
narayana_periods = None
|
||||
try:
|
||||
narayana_periods = narayana_dasha.calc_narayana_mahadasha(
|
||||
ascendant_index,
|
||||
planet_longitudes,
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
narayana_periods = None
|
||||
|
||||
birth_info = chart.get("birth_info") if isinstance(chart.get("birth_info"), dict) else {}
|
||||
kp_snapshot = observe_kp_cusps(
|
||||
birth_info.get("julian_day"),
|
||||
@@ -538,6 +598,10 @@ def build_candidate_static_context(
|
||||
"arudha_padas": arudha_padas,
|
||||
"varga_charts": varga_charts,
|
||||
"feature": feature_payload,
|
||||
"ashtakavarga_result": ashtakavarga_result,
|
||||
"shadbala_result": shadbala_result,
|
||||
"vimshottari_timeline": vimshottari_timeline,
|
||||
"narayana_periods": narayana_periods,
|
||||
}
|
||||
|
||||
|
||||
@@ -572,7 +636,12 @@ def _candidate_row(
|
||||
missing_layers.extend(prefixes)
|
||||
continue
|
||||
try:
|
||||
vimshottari = _active_vimshottari(candidate_at.date().isoformat(), moon_longitude, event_at)
|
||||
vimshottari = _active_vimshottari(
|
||||
candidate_at.date().isoformat(),
|
||||
moon_longitude,
|
||||
event_at,
|
||||
context.get("vimshottari_timeline"),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
missing_layers.append("Vimshottari_MD_AD_PD")
|
||||
continue
|
||||
@@ -582,6 +651,7 @@ def _candidate_row(
|
||||
planet_longitudes,
|
||||
candidate_at,
|
||||
event_at,
|
||||
context.get("narayana_periods"),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
missing_layers.append("Narayana_MD_AD")
|
||||
@@ -598,16 +668,30 @@ def _candidate_row(
|
||||
narayana=narayana,
|
||||
arudha_padas=arudha_padas,
|
||||
))
|
||||
transit_rules = _controlled_transit_rules(request, event, ascendant_index, DOMAIN_CONFIG[event["domain"]][1])
|
||||
transit_rules = _controlled_transit_rules(
|
||||
request,
|
||||
event,
|
||||
ascendant_index,
|
||||
DOMAIN_CONFIG[event["domain"]][1],
|
||||
context.get("_transit_chart_cache"),
|
||||
)
|
||||
if transit_rules:
|
||||
evidence[-1]["rule_ids"].extend(transit_rules)
|
||||
evidence[-1]["points"] = round(evidence[-1]["points"] + 0.25 * len(transit_rules) * precision_weight(event["precision"]), 4)
|
||||
av_rules, av_points = _ashtakavarga_auxiliary(chart, ascendant_index, DOMAIN_CONFIG[event["domain"]][1])
|
||||
av_rules, av_points = _ashtakavarga_auxiliary(
|
||||
chart,
|
||||
ascendant_index,
|
||||
DOMAIN_CONFIG[event["domain"]][1],
|
||||
context.get("ashtakavarga_result"),
|
||||
)
|
||||
if av_rules:
|
||||
evidence[-1]["rule_ids"].extend(av_rules)
|
||||
evidence[-1]["points"] = round(evidence[-1]["points"] + av_points * precision_weight(event["precision"]), 4)
|
||||
shadbala_rules, shadbala_points = _shadbala_verified_components_auxiliary(
|
||||
chart, candidate_at.hour + candidate_at.minute / 60, vimshottari,
|
||||
chart,
|
||||
candidate_at.hour + candidate_at.minute / 60,
|
||||
vimshottari,
|
||||
context.get("shadbala_result"),
|
||||
)
|
||||
if shadbala_rules:
|
||||
evidence[-1]["rule_ids"].extend(shadbala_rules)
|
||||
@@ -719,4 +803,8 @@ def compute_event_candidate_rows(
|
||||
) -> list[CandidateScoreRow]:
|
||||
"""Return every computed minute row while reusing one static chart scan per candidate."""
|
||||
contexts = list(static_contexts) if static_contexts is not None else compute_candidate_static_contexts(request, candidates=candidates)
|
||||
return [_candidate_row(request, context) for context in contexts]
|
||||
transit_chart_cache: dict[tuple[Any, ...], dict[str, Any]] = {}
|
||||
return [
|
||||
_candidate_row(request, {**context, "_transit_chart_cache": transit_chart_cache})
|
||||
for context in contexts
|
||||
]
|
||||
|
||||
@@ -697,7 +697,11 @@ def build_refinement_packet(
|
||||
"unique_minute_claim": False,
|
||||
"confirmation_allowed": False,
|
||||
}
|
||||
from scripts.rectification.candidate_contrast import PROBE_PHASE_HOLDOUT_VALIDATION, event_year
|
||||
from scripts.rectification.candidate_contrast import (
|
||||
PROBE_PHASE_HOLDOUT_VALIDATION,
|
||||
event_year,
|
||||
opportunity_from_probe,
|
||||
)
|
||||
from scripts.rectification.case_holdout import reserved_holdout_events
|
||||
from scripts.rectification.event_probes import (
|
||||
candidate_contrast_opportunities,
|
||||
@@ -729,13 +733,16 @@ def build_refinement_packet(
|
||||
dropped = list(bundle["dropped"])
|
||||
clarification = event_clarification_probes(request)
|
||||
collection = evidence_collection_probes(request)
|
||||
opportunities = candidate_contrast_opportunities(
|
||||
request,
|
||||
built,
|
||||
scan=scan,
|
||||
candidate_times=grid_times,
|
||||
representative_time=representative_time,
|
||||
)
|
||||
if probe_times == grid_times:
|
||||
opportunities = [opportunity_from_probe(probe) for probe in probes]
|
||||
else:
|
||||
opportunities = candidate_contrast_opportunities(
|
||||
request,
|
||||
built,
|
||||
scan=scan,
|
||||
candidate_times=grid_times,
|
||||
representative_time=representative_time,
|
||||
)
|
||||
reserved = reserved_holdout_events(request.get("events") or [])
|
||||
holdout = [
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@ import json
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Sequence
|
||||
from datetime import date, timedelta
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from scripts.active_rectification_event_engine import compute_candidate_static_contexts, compute_event_candidate_rows
|
||||
@@ -126,11 +125,6 @@ def _canonical(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _cached_rows(serialized: str) -> tuple[CandidateScoreRow, ...]:
|
||||
return tuple(compute_event_candidate_rows(json.loads(serialized)))
|
||||
|
||||
|
||||
_SUPPORT_RULES = (
|
||||
"functional_benefic_auxiliary",
|
||||
"arudha_auxiliary",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,374 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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 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"]
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user