Files
Jyotisha/docs/tasks/TASK-rectification-engine-memoization-20260915.md
T
Jesse_ChenandClaude Opus 5 a8d29d1b6c docs(tasks): 记录产品两项决定(分类用会话模型 / 年份采样不改)
产品 2026-09-15 对审计单里挂着的两个待拍板项给出结论,两项都是「不改」:

- 意图分类继续用会话选定的模型,不引入便宜快模型,模型目录不新增
  「工具模型」角色。failure-attribution 单 §4.6 立为决策记录,§10 从
  「待产品拍板后另开单」改为已决定不做;本单只改归因,不改用哪个模型。
- 只给年份的事件继续采满 12 个月,降采样不做、研究单也不立。
  engine-memoization 单 §4.2 / §11 同步,并写明后续不得以性能为由重提。

两条都加了「要重提必须先拿到产品新的授权」,避免下一轮被当成遗漏又提一次。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-15 17:17:29 +00:00

14 KiB
Raw Blame History

TASK · 重算里一半 CPU 在重复算同一件事(纯 Python,不改任何结果)

  • 日期:2026-09-15
  • 基线 commitorigin/staging @ 6b3248bf
  • 执行分支:codex/rectification-engine-memoization-20260915
  • 独占文件:scripts/active_rectification_event_engine.pyscripts/rectification/scoring_service.pyscripts/rectification/refinement_packet.py
  • 与本日其它三单无文件重叠,可并行
  • 规模:三处记忆化 + 删一段死代码。输出必须逐字不变。

1. 为什么现在做

生时校正每记一件证据、每答一道题,都要打一次 POST /api/rectification/v5/score,用户在那里干等。本机实测(Python 3.13 + swisseph,生产 2 vCPU 只会更慢):

场景 候选分钟 事件数 日期精度 耗时
开场整日扫描(minute_step=10 144 3 到月 3 578 ms
采集中期 · 60 分钟窗 61 6 到月 3 387 ms
采集中期 · 30 分钟窗 31 15 到月 2 458 ms
收敛后 · 20 分钟窗 31 20 到月 2 175 ms
同一批事件,只给年份 31 10 只到年 4 908 ms
同一批事件,给到月 31 10 到月 1 891 ms

生产主机 2 vCPU,重算闸门并发 2、饱和直接 429 不排队,domain_calculation_service._SWISSEPH_LOCK 又把所有排盘串行,加上 GIL,有效吞吐约等于一次一个。把下面这一半浪费去掉等于吞吐翻倍,比加机器便宜。

2. 事故实证

对一次「61 候选 × 6 事件」的 score_candidates 做 cProfile(总计 7.96 秒,tottime/cumtime 取自同一次采样):

被重复的计算 实际调用 应调用 占本次 CPU 它真正依赖什么
shadbala.calc_shadbala(经 _shadbala_verified_components_auxiliary 2 196 61 45 %3.60 s 只依赖候选分钟
domain_calculation_service.compute_chart(经 _controlled_transit_rules 2 196 36 12 % 只依赖事件日期
_discriminating_event_probe_lists 2 1 9 % 同一次请求算了两遍
narayana_dasha.calc_narayana_mahadasha + dasha_analyzer.build_dasha_timeline 6 876 / 39 852 61 8 % 只依赖候选分钟
ashtakavarga.calc_ashtakavarga(经 _ashtakavarga_auxiliary 2 196 61 3 % 只依赖候选分钟

2196 = 61 候选 × 36 个(事件, 采样日期)组合。三段调用链,符号定位:

  1. scripts/rectification/scoring_service.pybuild_event_contribution_matrix:对每个 event 的每个 sample_event_dates() 采样日各调一次 provider(...)providercompute_event_candidate_rows(value, static_contexts=static_contexts)static_contexts 只算一次(正确),但每次调用都会把 61 个候选全遍历一遍。
  2. scripts/active_rectification_event_engine.pycompute_event_candidate_rows_candidate_row(request, context):在这一层里对 request["events"] 循环,逐事件调用 _active_vimshottari / _active_narayana / _controlled_transit_rules / _ashtakavarga_auxiliary / _shadbala_verified_components_auxiliary。这五个里有四个的结果与 event 无关。
  3. scripts/rectification/refinement_packet.pybuild_refinement_packet:先 discriminating_event_probe_set(..., candidate_times=probe_times, precision_current=...),紧接着 candidate_contrast_opportunities(..., candidate_times=grid_times)。两者最终都落到 event_probes._discriminating_event_probe_lists,而该函数体第一行就是 del precision_current, representative_time —— 这两个参数根本不参与计算。因此 probe_times == grid_times 时(非 refresh_probes 路径,即绝大多数轮次)两次调用的入参在语义上完全相同。

最刺眼的一条:build_candidate_static_context 每个候选分钟已经算过一次 calc_shadbalacalc_ashtakavarga,但只取 _feature_hash(...) 写进 feature_payload["fingerprints"],结果对象随即丢弃;内层再从头算 36 遍。

另有死代码:scoring_service._cached_rows@lru_cache(maxsize=4096))全仓无调用方。build_event_contribution_matrix 只在 row_provider is None and static_contexts is None 时才不走 static context,而生产入口 score_candidates 永远两者都是 None → 走 static context 分支 → 绕过 _cached_rows。缓存加在了错误的层上。

3. 根因

build_candidate_static_context 这个「每个候选分钟只算一次」的抽象是对的,但只放进了排盘与分盘chart / vargas / arudha / KP / 特殊上升)。同样只依赖候选分钟的 Shadbala、Ashtakavarga、两条 Dasha 时间轴 留在了 _candidate_row 的事件循环里,于是被「候选 × 事件 × 采样日期」三重放大。过境盘则是反向的同一个错误:它只依赖事件日期,却被放在按候选分钟迭代的最内层。

4. 决策记录

产品 2026-09-15 授权本单,范围严格限定为:

  1. 只做记忆化,不改任何算法、权重、阈值、采样规则。 本单交付后,任何一个存量 Case 重算出来的 candidate_scoresdecision_receiptcandidate_feature_snapshot 必须与改前逐字相同。不需要重新校准,不影响任何已有结论。
  2. 不碰 sample_event_dates。产品 2026-09-15 明确决定:只给年份的事件采样方式不改,也不立研究单。 表里「只给年份 4 908 ms vs 给到月 1 891 ms」的 2.6 倍差来自 year 精度采满 12 个月;降采样会改变打分,产品选择保留现状。本单不得顺手改,后续轮次也不得以「性能」为由重提——要重提必须先拿到产品新的授权。
  3. 不修 build_candidate_static_context 里 Shadbala 的 birth_hour / birth_minute 双算。 该处传 birth_hour = hour + minute/60 的同时又传 birth_minute = minutecalc_kala_bala 因此把分钟算了两次。它只流进 fingerprints.shadbalafingerprints.static,改了会让所有候选特征指纹变化。本单按现状照搬,记为观察项,不修。
  4. 不动 _SWISSEPH_LOCK、不动并发闸门、不引入多进程。 那是另一个量级的改动,先把浪费去掉再谈。

5. 硬红线

  1. 输出等价是本单唯一的成败判据。任何一处记忆化如果不能证明等价,就不做那一处,其余照做。
  2. 记忆化的作用域是单次请求内,不得引入跨请求的进程级缓存(lru_cache 在模块级会跨请求持有出生资料派生数据,违反 §8 隐私边界,也会在窗口收窄后返回陈旧上下文)。允许的载体只有:build_candidate_static_context 返回的 context dict,以及 build_event_contribution_matrix / build_refinement_packet 调用栈内显式传递的局部字典。
  3. Shadbala 复用必须逐项证明:_shadbala_verified_components_auxiliary 只读 sthana_bala.totaldrik_balanaisargika_bala 三项;birth_minute 只进 calc_kala_bala。要么只复用这三项,要么在测试里断言两种调用方式下这三项相等。不得「看起来一样就换」。
  4. 过境盘缓存只缓存 chart,不缓存 _controlled_transit_rules 的返回值——后者还依赖 natal_ascendant_index(每个候选不同)和 target_houses
  5. 探针去重只在 probe_timesgrid_times 相等时生效;refresh_probes 路径两者不同,必须仍然各算一次。
  6. scripts/jyotish_api_server.py 一行不许动(AGENTS §6 增长冻结)。
  7. 不得顺手升级依赖、不得顺手修不在本单里的 warning。

6. 任务分解

6.1 Shadbala / Ashtakavarga 进 static context

build_candidate_static_context 已经算出 ashtakavarga_resultshadbala_result。把这两个对象(或它们被下游真正读取的字段)挂进返回的 context,_candidate_row 改为从 context 取,_ashtakavarga_auxiliary / _shadbala_verified_components_auxiliary 改成接收已算好的结果、只做后续的判定与计分。

  • 验收:新增 pytest 用 monkeypatch 计数,断言一次 score_candidates(≥2 事件、≥2 个 year 精度采样)里 shadbala.calc_shadbalaashtakavarga.calc_ashtakavarga 的调用次数各等于候选分钟数,不随事件数或采样日数增长。
  • 验收:同一请求改前/改后的 candidate_scoresdecision_receipt(剔除 column_compare_ms 等计时字段)逐字相等。

6.2 两条 Dasha 时间轴进 static context

_active_vimshottari 里的 lon_to_nakshatra + build_dasha_timeline 只依赖 (birth_date, moon_longitude)_active_narayana 里的 calc_narayana_mahadasha 只依赖 (ascendant_index, planet_longitudes)。两者都是每候选一份。把时间轴/周期表算好放进 context,两个函数只保留随 event_at 变化的 find_current / get_current_narayana_dasha 部分。

  • 验收:计数断言 build_dasha_timelinecalc_narayana_mahadasha 各等于候选分钟数。
  • 验收:输出等价同 6.1。

6.3 过境盘按事件日期缓存

_controlled_transit_rules 里的 compute_chart 只依赖 (event_at.date(), lat, lon, tz, ayanamsa, node_mode)。在 compute_event_candidate_rows 的调用栈内传一个局部字典做缓存;规则判定(_relative_housetarget_houses 比对)仍按候选逐个算。

  • 验收:计数断言这条链上的 compute_chart 调用次数等于去重后的事件日期数,与候选分钟数无关。
  • 验收:event["precision"] == "year" 仍然早退返回 [](现状行为,不得改)。

6.4 鉴别探针只算一次

build_refinement_packet 里,当 probe_times == grid_times 时把 discriminating_event_probe_set 的结果复用给 candidate_contrast_opportunities(后者本体就是 [opportunity_from_probe(p) for p in probes])。probe_times != grid_times 时保持两次调用。

  • 验收:计数断言默认路径下 _discriminating_event_probe_lists 调用一次;refresh_probes=True 且 refresh 列存在时仍为两次。
  • 验收:refinement_packet 输出的 discriminating_event_probescandidate_contrast_opportunities 两个字段改前后逐字相等。

6.5 删掉 _cached_rows

scoring_service._cached_rows 与随之无用的 _canonical 引用(若确认无其它调用方)一并删除。不要试图「把它接回去」——正确的缓存位置是 6.1–6.3,不是这里。

  • 验收:grep -rn "_cached_rows" scripts/ tests/skills/ 下的历史版本副本外无命中(skills/jyotish-vedic-astrology/versions/* 是冻结归档,不得改)。

6.6 落盘一份等价证明

tests/ 下新增一个 golden 等价测试:固定一份请求(公开示例数据,禁止真实用户出生资料),把改动前跑出的 candidate_scores + decision_receipt(剔除计时字段)存为 golden,改动后断言相等。golden 必须由基线 6b3248bf 的代码真实跑出,不得手造(AGENTS §7.4)。

  • 验收:该测试在改动前后都能跑,改动前绿、改动后仍绿。

7. 预期收益(本机已实测)

不改仓库代码、在基准脚本里把这几层用等价记忆化包起来,同一份请求(61 候选 × 6 事件)跑前后两次:

耗时
当前代码 3 358 ms
记忆化后 1 604 ms

快 53 %。 candidate_scores 完全相同;decision_receipt 的唯一差异是计时字段 column_compare_ms 10.5 → 10.4。这个数是本单的合格线参照,不是硬指标——实际收益随事件数与精度分布浮动,但等价是硬指标

8. 让步顺序

做不完时按此顺序砍,每砍一条在进度记录里写明原因:

  1. 6.1(Shadbala)必须做,它一个人占 45 %。
  2. 6.3(过境盘)次之,12 %,改动最独立。
  3. 6.4(探针去重)再次之,9 %。
  4. 6.2(Dasha 时间轴)可以留到下一轮,它的调用链最长、等价证明最费事。
  5. 6.5 顺手。
  6. 6.6 不得砍——没有等价证明的性能改动一律视为未通过。

9. 开工前置命令

git fetch origin --prune
git worktree add -b codex/rectification-engine-memoization-20260915 \
  .worktrees/rectification-engine-memoization-20260915 origin/staging
cd .worktrees/rectification-engine-memoization-20260915
git status -sb | head -1        # 确认分支

验收命令:

.venv/bin/python -m pytest tests/test_rectification_v5_services.py \
  tests/test_rectification_event_probes.py \
  tests/test_rectification_refinement_packet.py \
  tests/test_active_rectification_events.py \
  tests/test_rectification_engine_convergence.py \
  tests/test_rectification_relative_support.py \
  tests/test_rectification_technique_contract.py
.venv/bin/python scripts/run_quality_gate.py --profile quick

10. BUG 编号起点

基线 6b3248bfdocs/BUG_HISTORY.md 最大号为 BUG-720。本单预占 BUG-721(一条:候选不变量被重复计算,重算耗时翻倍)。开工时以当时的实际最大号 +1 为准;同日另有三单在跑,编号以先落库者为准。

BUG-721 的记录必须写明:这不是回归,是自 build_candidate_static_context 引入以来一直存在的分层遗漏;防复发写成「新增的候选分钟不变量必须进 static context,不得留在 _candidate_row 的事件循环里;新增的事件不变量不得按候选迭代」。

11. 不在本单范围

  • year 精度采样 12 → N 的降采样(产品 2026-09-15 已决定不改,研究单也不立,见 §4.2
  • _SWISSEPH_LOCK、并发闸门、多进程、加机器
  • 前端侧的等待体验(另有三单)
  • build_candidate_static_contextbirth_minute 双算(§4.3 记为观察项)