产品 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
171 lines
14 KiB
Markdown
171 lines
14 KiB
Markdown
# 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 记为观察项)
|