产品 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
14 KiB
TASK · 重算里一半 CPU 在重复算同一件事(纯 Python,不改任何结果)
- 日期:2026-09-15
- 基线 commit:
origin/staging@6b3248bf - 执行分支:
codex/rectification-engine-memoization-20260915 - 独占文件:
scripts/active_rectification_event_engine.py、scripts/rectification/scoring_service.py、scripts/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 个(事件, 采样日期)组合。三段调用链,符号定位:
scripts/rectification/scoring_service.py→build_event_contribution_matrix:对每个 event 的每个sample_event_dates()采样日各调一次provider(...),provider是compute_event_candidate_rows(value, static_contexts=static_contexts)。static_contexts只算一次(正确),但每次调用都会把 61 个候选全遍历一遍。scripts/active_rectification_event_engine.py→compute_event_candidate_rows→_candidate_row(request, context):在这一层里对request["events"]循环,逐事件调用_active_vimshottari/_active_narayana/_controlled_transit_rules/_ashtakavarga_auxiliary/_shadbala_verified_components_auxiliary。这五个里有四个的结果与event无关。scripts/rectification/refinement_packet.py→build_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_shadbala 和 calc_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 授权本单,范围严格限定为:
- 只做记忆化,不改任何算法、权重、阈值、采样规则。 本单交付后,任何一个存量 Case 重算出来的
candidate_scores、decision_receipt、candidate_feature_snapshot必须与改前逐字相同。不需要重新校准,不影响任何已有结论。 - 不碰
sample_event_dates。产品 2026-09-15 明确决定:只给年份的事件采样方式不改,也不立研究单。 表里「只给年份 4 908 ms vs 给到月 1 891 ms」的 2.6 倍差来自 year 精度采满 12 个月;降采样会改变打分,产品选择保留现状。本单不得顺手改,后续轮次也不得以「性能」为由重提——要重提必须先拿到产品新的授权。 - 不修
build_candidate_static_context里 Shadbala 的birth_hour/birth_minute双算。 该处传birth_hour = hour + minute/60的同时又传birth_minute = minute,calc_kala_bala因此把分钟算了两次。它只流进fingerprints.shadbala→fingerprints.static,改了会让所有候选特征指纹变化。本单按现状照搬,记为观察项,不修。 - 不动
_SWISSEPH_LOCK、不动并发闸门、不引入多进程。 那是另一个量级的改动,先把浪费去掉再谈。
5. 硬红线
- 输出等价是本单唯一的成败判据。任何一处记忆化如果不能证明等价,就不做那一处,其余照做。
- 记忆化的作用域是单次请求内,不得引入跨请求的进程级缓存(
lru_cache在模块级会跨请求持有出生资料派生数据,违反 §8 隐私边界,也会在窗口收窄后返回陈旧上下文)。允许的载体只有:build_candidate_static_context返回的 context dict,以及build_event_contribution_matrix/build_refinement_packet调用栈内显式传递的局部字典。 - Shadbala 复用必须逐项证明:
_shadbala_verified_components_auxiliary只读sthana_bala.total、drik_bala、naisargika_bala三项;birth_minute只进calc_kala_bala。要么只复用这三项,要么在测试里断言两种调用方式下这三项相等。不得「看起来一样就换」。 - 过境盘缓存只缓存 chart,不缓存
_controlled_transit_rules的返回值——后者还依赖natal_ascendant_index(每个候选不同)和target_houses。 - 探针去重只在
probe_times与grid_times相等时生效;refresh_probes路径两者不同,必须仍然各算一次。 scripts/jyotish_api_server.py一行不许动(AGENTS §6 增长冻结)。- 不得顺手升级依赖、不得顺手修不在本单里的 warning。
6. 任务分解
6.1 Shadbala / Ashtakavarga 进 static context
build_candidate_static_context 已经算出 ashtakavarga_result 与 shadbala_result。把这两个对象(或它们被下游真正读取的字段)挂进返回的 context,_candidate_row 改为从 context 取,_ashtakavarga_auxiliary / _shadbala_verified_components_auxiliary 改成接收已算好的结果、只做后续的判定与计分。
- 验收:新增 pytest 用 monkeypatch 计数,断言一次
score_candidates(≥2 事件、≥2 个 year 精度采样)里shadbala.calc_shadbala与ashtakavarga.calc_ashtakavarga的调用次数各等于候选分钟数,不随事件数或采样日数增长。 - 验收:同一请求改前/改后的
candidate_scores与decision_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_timeline与calc_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_house 与 target_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_probes与candidate_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. 让步顺序
做不完时按此顺序砍,每砍一条在进度记录里写明原因:
- 6.1(Shadbala)必须做,它一个人占 45 %。
- 6.3(过境盘)次之,12 %,改动最独立。
- 6.4(探针去重)再次之,9 %。
- 6.2(Dasha 时间轴)可以留到下一轮,它的调用链最长、等价证明最费事。
- 6.5 顺手。
- 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 编号起点
基线 6b3248bf 上 docs/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_context的birth_minute双算(§4.3 记为观察项)