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

171 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 个(事件, 采样日期)组合`。三段调用链,符号定位:
1. `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 个候选全遍历一遍。
2. `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` 无关。
3. `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 授权本单,范围严格限定为:
1. **只做记忆化,不改任何算法、权重、阈值、采样规则。** 本单交付后,任何一个存量 Case 重算出来的 `candidate_scores``decision_receipt``candidate_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 = minute``calc_kala_bala` 因此把分钟算了两次。它只流进 `fingerprints.shadbala``fingerprints.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.total``drik_bala``naisargika_bala` 三项;`birth_minute` 只进 `calc_kala_bala`。要么只复用这三项,要么在测试里断言两种调用方式下这三项相等。不得「看起来一样就换」。
4. 过境盘缓存只缓存 **chart**,不缓存 `_controlled_transit_rules` 的返回值——后者还依赖 `natal_ascendant_index`(每个候选不同)和 `target_houses`
5. 探针去重只在 `probe_times``grid_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_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. 让步顺序
做不完时按此顺序砍,每砍一条在进度记录里写明原因:
1. 6.1(Shadbala)必须做,它一个人占 45 %。
2. 6.3(过境盘)次之,12 %,改动最独立。
3. 6.4(探针去重)再次之,9 %。
4. 6.2(Dasha 时间轴)可以留到下一轮,它的调用链最长、等价证明最费事。
5. 6.5 顺手。
6. 6.6 **不得砍**——没有等价证明的性能改动一律视为未通过。
## 9. 开工前置命令
```bash
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 # 确认分支
```
验收命令:
```bash
.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 记为观察项)