Files
Jyotisha/docs/tasks/TASK-rect-scoring-perf-20261013.md
T
Jesse_ChenandClaude Opus 5.5 2994588ad4 docs(tasks): fold product decisions into audit briefs (BUG-1344..1346)
- dasha-unify: fix Ashtottari birth balance (BUG-1344)
- rect-scoring-perf: server-side candidate cap 241 (BUG-1345)
- node-runtime-resources: enable user-side session cookie cache, 300 s (BUG-1346)
- consult-prep-parallel: classifier/reserve concurrency declined
- answer-effort-ab: model key supplied by product at acceptance
- drop stricter-than-test line-count red lines (AGENTS.md section 6)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-14 00:41:02 +08:00

337 lines
32 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 · 校正打分提速:去掉重复计算、精简响应(issue #待定)
- 日期:2026-10-13
- 来源:「2026-10-13 全仓屎山与性能排查」· 校正打分 `POST /api/rectification/v5/score` 慢与响应过大(P0,确定性计算提速)
- 执行方:**Grok(Windows + Docker)**,分支 `codex/rect-scoring-perf-20261013`,只推这个分支
- 验收:Claude(Linux)
- BUG 编号:**BUG-1323~BUG-1325、BUG-1345**(开工时核对 `docs/BUG_HISTORY.md` 最大号,写作时最大号 BUG-1317)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起)
- 开工前置:无(第一批立即派发)
- 性质:**输出逐字节不变**的性能改动。算法身份保持 `rectification-v5-matrix-scoring-12`、策略 `rectification-candidate-policy-v4`,不升版本、不写新 golden。
## 1. 基线
- 基线 commit:`origin/staging` `77057a819`
- 调用链:前端 `frontend/src/lib/rectification-agentic/v9/engine-client.ts::runV9CandidateScore` → `POST /api/rectification/v5/score`(超时 60 s,`busyRetry`)→ `scripts/jyotish_api_server.py::_compute_rectification_v5_score` → `scripts/rectification/api_service.py::score_candidates` → `scoring_service.py::build_event_contribution_matrix`(含 `dasha_transition_proximity.py::merge_transition_proximity`)→ `decision_policy.py::build_decision_receipt` → `refinement_packet.py::build_refinement_packet` → `event_probes.py::guided_collect_windows`。
- 时段扫描 `block_scan` 也走 `score_candidates`,同样受益。
- 生产窗口有多大(已核实,修正排查报告的假设):
- `engineRequestBody` 不传 `minute_step`;`contracts.py::normalize_rectification_request` 只在 ≠1 时保留该键,引擎按 `request.get("minute_step") or 1` 取 **1 分钟**。
- `search-window.ts`:`MINUTE_GRID_MAX_SPAN_MINUTES = 120`,跨度 ≤120 分钟才进分钟阶段(打分接口),所以**生产单次打分最多 121 个候选**;更宽的窗口走 `block_scan`,步长 2/5/10(全天 10 分钟 = 144 候选)。
- 排查报告里「4 小时 240 候选 12.3 s」「12 小时 721 候选 117 s」在产品流程里到不了,只在直接调用接口时出现(服务端没有候选数上限,本单不做,见 §10)。
## 2. 事故实证(`77057a819`,本机 8 核、有其他会话并行负载,数字有 ±20% 抖动)
复现夹具(虚构事件 + 公开 1990-01-01 北京烟测盘,与 `tests/test_rectification_engine_memoization.py::public_score_request` 同一张盘):
```python
EVENTS = [ # id 依次 00000000-0000-4000-8000-00000000000{1..8},summary 统一 "虚构"
("education","education_start","2008-01-01","2008-12-31","year"),
("career","career_entry","2012-06-01","2012-06-30","month"),
("relocation","relocation","2014-03-01","2014-03-31","month"),
("relationship","relationship_commitment","2016-10-01","2016-10-31","month"),
("career","promotion","2018-01-01","2018-12-31","year"),
("finance","asset_change","2019-05-01","2019-05-31","month"),
("family","family_event","2020-08-01","2020-08-31","month"),
("health","self_health_event","2022-01-01","2022-12-31","year"),
]
# 请求:birth_date 1990-01-01, lat 39.9, lon 116.4, tz 8;normalize_rectification_request(body, today=date(2026,9,16))
# event_probes.date 打补丁冻结在 2026-09-16(同记忆化测试 _FrozenDate)
```
### 2.1 总耗时与响应大小(`score_candidates` 无 profiler,墙钟)
| 窗口 | 候选 | 耗时 | 响应 JSON |
| --- | --- | --- | --- |
| 12:00–12:20 | 21 | 0.45–0.50 s | 393 KB |
| 11:45–12:15(常见 ±15) | 31 | 0.78 s | — |
| 11:00–13:00(分钟阶段上限) | 121 | **3.8–4.8 s** | **1.91 MB** |
| 10:00–14:00(产品到不了) | 241 | 10.5 s | 3.45 MB |
| `block_scan` 00:00–23:59 步长 10 | 144 | 2.35–2.6 s | 310 KB |
生产 2 vCPU、重计算闸门 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY=2`:一次 121 候选打分占一个闸门槽 4 s 以上,两人同时答题第三个就 429。
### 2.2 121 候选的分段(计时包装,含少量包装开销)
| 段 | 耗时 | 调用次数 |
| --- | --- | --- |
| `event_probes.py::guided_collect_windows` | 2.3 s(cProfile 下占总时间 68%) | 1 次;内部 `_context_time` **2,782,032 次**、`_guided_year_windows` 22,982 次 |
| `merge_transition_proximity` | 0.8–1.0 s | 1 次;每候选×每个事件年份区间各算一遍大运起点 |
| `_active_narayana` → `get_current_narayana_dasha` | ≈0.5 s | 6,171 / 6,519 次;`_subdivide_narayana_period` 25,350 次 |
| `functional_benefics.derive_functional_benefic_malefic` | ≈0.2 s | 6,519 次(只依赖上升星座,每候选只需 1 次) |
| 候选静态上下文 | 0.5 s | 1 次(BUG-721 已收敛,正常) |
**更正排查报告**:报告里 `derive_functional_benefic_malefic` ≈1.05 s、`_active_narayana` ≈2.4 s、`merge` 最高 6 s 是 cProfile 放大后的数字(cProfile 下整次 18.8 s)。无 profiler 时这三项合计约 1.5 s,大头是 `guided_collect_windows`。
### 2.3 原型验证(scratch 原型,不入库,只证明可行)
同进程、同一请求,剔除计时字段 `column_compare_ms` 后整份响应逐字节比较:
| 原型 | 121 候选 | 一致性 |
| --- | --- | --- |
| `guided_collect_windows` 按(轨道, 边界序号)去重 | 1.89 s → 0.37 s(函数本身,5×);`_context_time` 2,782,032 → 1,331(每候选 11 次) | 全候选 / 半数 / 3 个 / 拒答领域 4 种入参全部一致 |
| `merge_transition_proximity` 每候选算一次全量起点再按年份过滤 | 0.79–0.95 s → 0.22–0.25 s | 一致 |
| 四项合并 | 整次 4.05 s → 1.9–2.7 s | 2 小时、跨午夜 23:20–00:40、拒答两领域、`refresh_probes`+`column_times`、步长 2 共 5 个场景全部一致 |
原型里功能吉凶用了 deepcopy、那罗延只做了 id 记忆,都比正式方案慢;按 §6 T3 的正式方案(进静态上下文、不复制、不算用不到的 PD),预计 121 候选可到 1.5 s 左右。
### 2.4 响应体(121 候选,紧凑 JSON 1.91 MB,带空格 2.04 MB)
| 键 | 大小 | 读取点 |
| --- | --- | --- |
| `event_contribution_matrix` | 804 KB | `frontend/src` **零读取**;`frontend/tests/rectification-v9-status-security.test.ts` 还断言它不得出现在前台投影里。Python:`tests/test_rectification_v5_services.py`(`assertIn`)、`scripts/research/midnight_date_anchor_regression.py::projection` 读 `result["event_contribution_matrix"]` |
| `candidate_decision_receipt` | 219 KB | 与 `decision_receipt` **是同一个 dict 序列化两遍**。`frontend/src` 零读取;`jyotish_api_server.py::_attach_vedastro_minute_sensitive_to_score` 已对缺键容错;`tests/test_rectification_v5_services.py` 两处读取;`diagnostics()` 也返回 |
| `candidate_feature_snapshot` | 417 KB | **有读取点**:`v9/segment-contract.ts::candidateSegmentMetadata` 读 `features[*]`。本单不动 |
去掉前两项:1.91 MB → 0.91 MB,gzip 89 KB → 65 KB。Python `json.dumps` 只省约 8 ms,所以这一项是卫生(BUG-728 规则),不是提速主力。`mcp_server.py` 不调用 v5 打分。
## 3. 根因
- **BUG-1323**:`guided_collect_windows`(2026-09-16 `cfb41daf3` 引入,BUG-747/749 期间)对每一对代表候选 × 4 条轨道 × 每个边界序号,都把**全部**候选重新扫一遍取 `_context_time` 再收集日期。收集结果只取决于(轨道, 边界序号),后续同一(轨道, 序号)产出的键都已在 `seen` 里,重复计算没有任何输出。复杂度 O(N²·B)。记忆化测试只用 3 个候选、引导窗口测试只用 5 个,没测到平方增长。**从未设计,不是回归。**
- **BUG-1324**:BUG-721 的防复发条款(「候选分钟不变量必须进 static context,不得留在事件循环里」)没有覆盖到三处:
1. `_score_event` 每个(候选 × 事件 × 采样日)都调一次 `derive_functional_benefic_malefic`,它只依赖上升星座;
2. `_active_narayana` 每次调用都通过 `get_current_narayana_dasha` 重新切分当前 MD 的 AD,还多算一层用不到的 PD;
3. `merge_transition_proximity`(09-01 `8743dcb10` 引入,09-20 改为候选日期)的缓存键带事件年份区间 `(lo, hi)`,同一候选对每个事件重新遍历整条大运时间轴。
同属 BUG-721 一族:旧条款还在,只是新代码没照做。
- **BUG-1325**:`score_candidates` 无条件返回内部矩阵,并把同一个回执挂两个键名,违反 BUG-728「没有读取点的字段不得进入前台响应」。
## 4. 决策记录
- 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行。本单为 P0 确定性提速:**不需要模型 A/B**,需要改前改后耗时对比与差分脚本。
- **不推翻任何红线**:
- BUG-721:记忆化只用请求内显式传递的 context 或局部字典,**禁止模块级 `lru_cache` / 全局 dict**(即使键只是上升星座也不行)。本单照此执行。
- BUG-733 / BUG-747:不得对浮点整体 `==` 比较 golden,不得调用 `write_golden()`。记忆化 golden v5 文件一个字节都不改。
- ERR-110 / BUG-1047:`event_probes.py`、`dasha_transition_proximity.py`、`scoring_service.py`、`api_service.py`、`refinement_packet.py`、`functional_benefics.py` 在 `scripts/research/sealed_holdout_rerun.py::PRODUCTION_FILES` 里;`active_rectification_event_engine.py`、`narayana_dasha.py` 在 dataset `frozen_scoring.files`(12 个遗留文件)里。**输出不变也必须按研究协议重新冻结**(§6 T6),否则不得合入。BUG-1047 当时就是因为没做这一步,同类优化半途放弃。
- **产品 2026-10-14 决定:打分接口设候选数上限**,数值由 Claude 评估定为 241(T4b 表格)。
- T4 默认不返回两个字段,是执行 BUG-728 的既有规则,不是新决定。Python 内部调用方要拿矩阵就显式传参。HTTP 接口不加开关(前台没有读取点)。
## 5. 硬红线
1. **输出逐字节不变**:同一请求下,除计时字段 `column_compare_ms` 外,`score_candidates` / `block_scan` / `diagnostics` 的返回值规范化 JSON(`sort_keys=True`)与基线完全相等;T4 删掉的两个键只在显式开关下出现,且出现时与基线逐字节相等。分数、排序、窗口、探针、回执、`candidate_feature_snapshot`(含 `feature_hash`)都不准变。
2. 不改算法、权重、阈值、采样规则、`GUIDED_COLLECT_LIMIT`、`MIN_BOUNDARY_DAYS`;不升 `ALGORITHM_VERSION` / `POLICY_VERSION`;不写新 golden。
3. 新的缓存只放进请求内的静态上下文**顶层**(与 `narayana_periods` 同级),**不得**放进 `feature` 负载(会改 `fingerprints` 与 `feature_hash`)。禁止模块级缓存(BUG-721)。
4. 不修改 `narayana_dasha.py`、`functional_benefics.py`、`dasha_analyzer.py` 的任何函数(共享模块,报告和咨询也在用)。
5. `scripts/jyotish_api_server.py` 不改(`_attach_vedastro_minute_sensitive_to_score` 已对缺键容错);`tests/test_api_server_growth_contract.py` 必须绿。
6. 不碰前端业务代码(唯一例外:T4b 在 `engine-client.ts` 错误映射里认出 `too_many_candidates`)与 `frontend/src/app/(app)/page.tsx`;不改 `.gitea/workflows/**`;不动表结构和迁移;不升级依赖;不顺手修别的 warning。
7. 改既有断言按 AGENTS §7.3 写「原值 / 新值 / 原因」三栏;开工先 grep:`grep -rn "event_contribution_matrix\|candidate_decision_receipt" tests scripts frontend/tests frontend/src`。
8. 冻结重跑只能**另存**新记录,不得覆盖 `ephemeris_nutation_2026_10_08` 的四份文件,也不得改历史归档(`test_historical_artifacts_are_byte_preserved_not_refreshed`)。
9. 跨进程比较一律固定 `PYTHONHASHSEED=0`(ERR-111:Shadbala 指纹随哈希种子变);同进程比较不受影响。
## 6. 任务分解
### T1 · `guided_collect_windows` 去掉平方扫描(BUG-1323)
- 改 `scripts/rectification/event_probes.py::guided_collect_windows`:
- 在算完 `starts_by_time` 后,按 `work` 的顺序预取每个候选的轨道表一次,例如 `work_tracks = [starts_by_time.get(_context_time(c) or "") or {} for c in work]`,内层循环改为遍历它。
- 用局部集合 `done: set[tuple[str, int]]` 记录处理过的(轨道, 边界序号)。只有在通过 `one == two` 检查后才登记;已登记的直接 `continue`。这是等价变换:同一(轨道, 序号)的 `gathered`、`cut`、`split`、`domain` 都一样,重复时产出的键全都已在 `seen` 里。
- 保留 `_representative_pairs` 的遍历顺序、`seen` 去重、最后的排序与截断,一行都不改。
- 不改:`_guided_track_starts`、`_guided_year_windows`、`_representative_pairs`、任何常量。
- 验收:
- 新测试 `tests/test_rect_scoring_perf_20261013.py`:
- 把基线 `guided_collect_windows` 原样复制进 `tests/_baseline_rect_scoring_20261013.py`,文件头写来源 commit `77057a819` 和 `git show 77057a819:scripts/rectification/event_probes.py` 的 sha256。
- 用 §2 夹具真实引擎的 121 候选 `built`,覆盖全候选 / 隔一取一 / 前 3 个 / `declined_domains=["relationship"]` 四种入参,新旧输出 `==`。
- **计数断言**:`_context_time` 在一次调用内的次数 ≤ 16 × 候选数;121 候选与 21 候选的次数比 ≤ (121/21) × 1.2,证明是线性的。
- `tests/test_event_probes_guided_windows.py` 全绿,断言不改。
- Grok 自跑。
### T2 · `merge_transition_proximity` 每候选只遍历一次大运(BUG-1324 之三)
- 改 `scripts/rectification/dasha_transition_proximity.py::merge_transition_proximity`:
- 每个候选的 key 取 `(candidate_date, round(moon, 6), asc)`,算一次不按年份过滤的 `_vim_start_dates`(AD 层、含 PD 层)和 `_narayana_start_dates`。年份参数传一个覆盖全部的区间,例如 `lo=1, hi=9999`;也可以在本模块里写不带年份参数的私有包装,但**不得改 `event_probes.py` 里这两个函数**。
- 每个事件再用 `[d for d in xs if lo <= d.year <= hi]` 过滤,保持原顺序。
- 等价依据:这两个函数只是在固定遍历顺序上按 `start.year` 过滤,返回 `None` 的条件与年份无关。执行方要在 PROGRESS 里引用 `event_probes.py::_vim_start_dates` / `_narayana_start_dates` 的源码行说明这一点;若发现不成立就停下来回报。
- 局部字典,函数返回即释放。
- 不改:`score_transition_proximity`、常量、`ad_set` / `pd_only` 的计算方式。
- 验收:
- 新测试:基线函数原样复制进 `_baseline_rect_scoring_20261013.py`。用 §2 夹具 121 候选,再加跨午夜 23:20–00:40 的矩阵,对 deepcopy 后的同一份矩阵分别跑新旧两版,结果 `==`。
- **计数**:一次调用内,`_vim_start_dates` 调用 ≤ 2 × 去重候选数,`_narayana_start_dates` ≤ 1 × 去重候选数。
- `tests/test_dasha_transition_proximity_cross_midnight.py` 全绿。
- Grok 自跑。
### T3 · 功能吉凶与那罗延 AD 表进静态上下文(BUG-1324 之一、之二)
- `scripts/active_rectification_event_engine.py::build_candidate_static_context` 的返回值**顶层**新增两个键,不进 `feature`:
- `functional_profile`:`functional_benefics.derive_functional_benefic_malefic(chart["ascendant"].get("sign"))`。
- `narayana_antardasha_by_md`:当 `narayana_periods` 非空时,等于 `{p["sign_idx"]: narayana_dasha.calc_narayana_antardasha(narayana_periods, p["sign_idx"]) for p in narayana_periods}`(键用 `sign_idx`,与 `get_current_narayana_dasha` 内部调用一致);否则 `None`。
- `_score_event` 增加可选参数 `functional: dict | None = None`,为 `None` 时照旧现算。`_candidate_row` 传 `context.get("functional_profile")`。`event_probes.py` 里调用 `_score_event` 的探针路径(约 809 行,`natal_chart=context["chart"]`)同样传 context 里的值。
- `_active_narayana` 增加可选参数 `antardasha_by_md`。为 `None` 时走原来的 `get_current_narayana_dasha`;不为 `None` 时:
- 用与 `narayana_dasha.get_current_narayana_dasha` **逐行相同**的 `total_cycle` / `cycle_start` / `age_in_cycle` 与 MD、AD 查找逻辑,只返回 `(md sign_idx, ad sign_idx)`,不算 PD。
- 在代码注释里注明照抄的源行号。
- `_candidate_row` 传 `context.get("narayana_antardasha_by_md")`。
- 探针路径(`event_probes.py` 约 799 行)**本单不改**:它不传 periods,改成传入会让它从「现算」变成「用缓存」,虽然等价,但不在本单范围。
- 回退路径必须保留:上下文缺这两个键(旧调用方、测试里手搓的上下文)时,结果与现在相同。
- 不改:`functional_benefics.py`、`narayana_dasha.py`(红线 4);`compute_event_candidate_rows`;事件循环结构。
- 验收:
- 扩展 `tests/test_rectification_engine_memoization.py` 的同进程 A/B(BUG-733 的做法):`CACHE_LAYER_KEYS` 加上这两个新键。A 带全部缓存、B 把六个键全置 `None`,`compute_event_candidate_rows` 输出严格相等。这是给常量加两项,属于扩展不是放宽,在 PROGRESS 里写三栏。
- **计数**:一次 `score_candidates` 内,`derive_functional_benefic_malefic` 调用次数 == 静态上下文个数(121 候选即 121,基线 6,519);`narayana_dasha.get_current_narayana_dasha` 在 `active_rectification_event_engine.py` 调用栈内 == 0(沿用该测试 `_count_calls(..., from_engine=True)` 的写法)。
- 性质测试:对 3 个虚构候选上下文,年龄从 0 到 120 岁、步长 0.01,`_active_narayana(…, antardasha_by_md=表)` 与 `antardasha_by_md=None` 结果全部相等;再加周期边界前后 ±1e-9 的点。
- `candidate_feature_snapshot` 的 `feature_hash` 与基线同进程相等(证明没进 `feature`)。
- Grok 自跑。
### T4 · 打分响应去掉无读取点字段(BUG-1325)
- `scripts/rectification/api_service.py::score_candidates(request, *, include_internal: bool = False)`:默认不放 `event_contribution_matrix` 和 `candidate_decision_receipt`;`include_internal=True` 时与基线逐字节一致。用函数参数,**不要**加成请求字段(加成请求字段会进 `fingerprint` / `result_id`)。
- `diagnostics()` 同样不再返回 `candidate_decision_receipt`(前端 `runV9Diagnostics` 只读 `decision_receipt`)。
- `block_scan` 本来就不返回这两个键,不动。
- 需要矩阵的 Python 调用方显式传 `include_internal=True`。至少包括 `scripts/research/midnight_date_anchor_regression.py`;开工时用 grep 找全,每处写进 PROGRESS。
- `tests/test_rectification_v5_services.py`:约 415 行 `assertIn("event_contribution_matrix", scored)`、约 493 / 595 行读 `candidate_decision_receipt`,按三栏改:默认断言 `assertNotIn`,另加一条 `include_internal=True` 时仍在;读回执的改读 `decision_receipt`(同一个对象)。`tests/test_rectification_v5_vedastro_validation.py` 约 301 行自造结果测服务端容错,保留。
- 验收:
- 新测试:121 候选默认响应不含两键;`include_internal=True` 的响应剔除计时字段后与基线逐字节相等;默认响应等于开关响应去掉这两个键。
- 响应大小(紧凑 JSON)记进 PROGRESS,目标 ≤ 基线的 50%(实测预期 1.91 MB → 0.91 MB)。
- `frontend/tests/rectification-v9-engine-contract.test.ts`、`rectification-v9-status-security.test.ts` 不受影响(Claude 复验)。
### T4b · 服务端候选数上限(BUG-1345,产品 10-14 决定)
**上限定为每次请求 241 个候选**(主会话评估,产品已授权设上限):
| 依据 | 数字 |
| --- | --- |
| 产品流程单次打分最多 | 分钟阶段 121(跨度 ≤120 分钟、步长 1);`block_scan` 全天步长 10 = 144,其余档位(2/5 步长)≤ 91 |
| 上限 241 的来历 | 等于前端已有常量 `search-window.ts::WIDEN_MAX_INCLUSIVE_MINUTES`(4 小时、每分钟一个),是产品最大用量的约 1.7 倍,留给以后放宽窗口的余量 |
| 上限下的最坏耗时 | 基线 241 候选 10.5 s;T1–T3 后预计约 4 s,不超过现在一次正常 121 候选打分 |
| 现在不设上限的最坏情况 | 直接调接口传全天步长 1 = 1,440 个候选,占重计算槽数分钟 |
- 实现:在 `scripts/rectification/api_service.py` 的 HTTP 入口路径(`score_candidates`、`block_scan`、`diagnostics`)按 `start_time` / `end_time` / 生效步长先算候选数,超过 241 直接拒绝,返回 **400**,错误码 `too_many_candidates`,消息里带上限值和本次候选数。**在开始排盘之前拒绝**,不得算到一半。
- 上限用模块常量 `MAX_CANDIDATES_PER_REQUEST = 241`;**不得**加成请求字段(会进 `fingerprint` / `result_id`)。研究脚本若需要超过上限,显式传关键字参数关闭;开工 grep 全部 Python 调用方(`scripts/research/**`、`tests/**`),确认 T6 冻结重跑的所有试验都在上限内、结果不变,逐一写进 PROGRESS。
- 前端:`engine-client.ts` 已有的错误映射里,把 400 `too_many_candidates` 归入「请求参数错」类(不重试、不当成忙);正常流程碰不到,只需保证不会被当成 429 去重试。若需改前端文件,只改错误映射这一处。
- 验收:
- 新测试:240 / 241 候选放行,242 候选 400;全天步长 1(1,440)400,且耗时 < 50 ms(证明没开始排盘);全天步长 10(144)放行;步长 2/5 的产品档位全部放行。
- `tests/test_api_server_security.py` 若有对打分接口状态码的断言,按三栏补。
- Grok 自跑;Claude 复验。
- T4b 必须在 T6 之前完成(`api_service.py` 在冻结身份清单里)。
### T5 · 差分与耗时脚本(交付物,T1–T4 都要用)
- 新增 `scripts/research/rect_scoring_perf_20261013.py`,内置 §2 夹具,两个子命令:
- `bench`:对 21 / 31 / 121 候选与 `block_scan` 全天步长 10 各跑 5 次取中位数,输出 JSON(窗口、候选数、中位耗时、分段耗时、响应字节数)。分段耗时只包 §2.2 那几段,计次器不得包 `_context_time` 这类热点小函数(包了会严重拖慢、失真)。
- `dump`:把 §6 场景矩阵的规范化输出(`sort_keys=True`、`ensure_ascii=True`、剔除 `column_compare_ms`、`include_internal=True`)逐场景写成文件,并打印各自的 sha256。
- 场景矩阵(至少):
1. 12:00–12:20;
2. 11:45–12:15;
3. 11:00–13:00;
4. 跨午夜 23:20–00:40;
5. 11:30–12:30 + `declined_domains: ["relationship","career"]`;
6. 11:30–12:30 + `refresh_probes: true` + `column_times: ["11:40","12:10","12:20"]`;
7. 10:00–13:00 `minute_step: 2`;
8. `block_scan` 00:00–23:59 步长 10;
9. `block_scan` 10:00–14:00 步长 5;
10. 记忆化测试的 `public_score_request()`;
11. `diagnostics` 用场景 3 的请求。
- 跨版本比较:
- 一次性工作树检出基线:`git worktree add --detach ../rsp-base 77057a819`;
- 两边都用 `PYTHONHASHSEED=0` 跑 `dump`;
- 11 个场景 sha256 全部相等;
- 输出写进 PROGRESS 表格。
- 同进程差分由 T1–T3 的测试承担(基线函数副本 + 缓存键置 `None`)。
- 跑完删除基线工作树。脚本只读,不写仓库以外的状态。
### T6 · 按 ERR-110 重新冻结(必须,否则不合入)
改了冻结身份文件,`tests/test_rectification_validation_integrity_gate.py`(快速门)必红。照 `docs/tasks/PROGRESS-ephemeris-nutation-20261008.md`「ERR-110」节的做法,标签用 `scoring_perf_2026_10_13`:
1. 所有代码改动(T1–T4)落定后才开始;之后若再改冻结文件,必须重做本步。
2. 冻结与重跑:
- `python scripts/research/sealed_holdout_rerun.py --freeze --freeze-path docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.freeze.json`,报告写到 `docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.json`(按脚本现有参数,必要时把模块常量 `FREEZE` / `REPORT` 指到新文件);
- `python scripts/research/reported_offset_sweep.py --freeze --freeze-path docs/research/reported_offset_scoring_perf_2026_10_13.freeze.json`,报告写到 `docs/research/reported_offset_scoring_perf_2026_10_13.json`(900 例,上次约 15 分钟)。
3. **正确性核对**(本单最强的端到端证据):
- 新旧两份报告的 `trials`(规范化 JSON)与 `ephemeris_nutation_2026_10_08` 两份报告**逐字节相等**;
- `metrics` / `summary` 相等;
- `historical_comparison.changed_trial_count` 分别仍是 8 和 581。
- 有任何一条不等,说明输出变了:停下回报,不得调参,不得推送。
4. 更新指针:
- `scripts/research/sealed_holdout_rerun.py` 与 `reported_offset_sweep.py` 的 `FREEZE` / `REPORT` 常量;
- `references/rectification_sealed_holdout.v1.json` 的当前树指针,把 nutation 放进 `previous_*`;
- `frontend/tests/rectification-confirmation-gate.test.ts` 的冻结路径;
- `tests/test_sealed_holdout_contract_freshness.py::IDENTITY_FORBIDDEN_PREFIXES` 加两条新前缀。
- 每一处写三栏,原因写「BUG-1323~1325 改了冻结身份文件,输出不变,ERR-110 另存新记录」。
5. nutation 四份文件一个字节不改。
- 验收:
- `pytest tests/test_rectification_validation_integrity_gate.py tests/test_sealed_holdout_contract_freshness.py tests/test_reported_offset_research.py tests/test_rectification_confirmation_and.py -q` 0 failed;
- 前端 `rectification-confirmation-gate.test.ts` 0 fail / 0 cancelled;
- 第 3 条的逐字节核对结果贴进 PROGRESS。
- Grok 跑;Claude 验收时复跑第 3 条比较。
### T7 · 记录
- `docs/BUG_HISTORY.md` 新增 **BUG-1323**(引导窗口平方扫描)、**BUG-1324**(候选不变量仍在事件循环:功能吉凶 / 那罗延 AD / 过渡邻近;关联 BUG-721,写明旧条款为何没拦住:新代码没照做、测试只用 3~5 个候选)、**BUG-1325**(打分响应带无读取点的内部矩阵与重复回执;关联 BUG-728)。防复发写进「计数断言随候选数线性」与「新响应字段先找读取点」。另新增 **BUG-1345**(打分接口无候选数上限,直接调用可占重计算槽数分钟;修复为 241 上限、超限 400 且不排盘)。
- `docs/research/pre_work_error_ledger.md`:在 ERR-110 下追加本次另存标签。
- `docs/tasks/PROGRESS-rect-scoring-perf-20261013.md`:改前 / 改后耗时表(5 次中位数,注明机器与负载)、11 场景 sha256 对照、计数表、三栏清单、冻结核对结果。
- `CHANGELOG.md`:一句「校正每轮重算更快(2 小时窗口约快一倍以上),结果不变」。Skill 版本不 bump。
- `frontend/DESIGN.md`:不改(无 UI 变化)。
## 7. 目标数字(同一台机器、同一次运行里 A/B 交替各 5 次取中位数)
| 指标 | 基线(本机实测) | 目标 |
| --- | --- | --- |
| 121 候选 `score_candidates` | 3.8–4.8 s | ≤ 基线中位数的 **45%**(本机约 ≤ 2.0 s) |
| 21 候选 | 0.45–0.5 s | 不慢于基线 +5% |
| `guided_collect_windows`(121) | ≈1.9–2.3 s | ≤ 基线的 25% |
| `merge_transition_proximity`(121) | 0.8–1.0 s | ≤ 基线的 35% |
| `block_scan` 全天步长 10 | 2.35–2.6 s | 不慢于基线;预期快约 25% |
| 121 候选响应(默认) | 1.91 MB | ≤ 0.96 MB |
| 正确性 | — | 11 场景 sha256 全等;冻结重跑 trials 全等 |
耗时目标没达到但正确性全过:照样交付,在 PROGRESS 里写实测与原因,由 Claude 判断。**正确性有一条不过:不交付。**
## 8. 让步顺序
时间不够时,从后往前砍:
1. T3 的那罗延 AD 表(收益约 0.3–0.4 s),写进 PROGRESS「未做」;
2. T3 的功能吉凶;
3. T4。
**T1、T2、T4b、T5、T6、T7 不可砍。** T6 必须在最终代码上做:砍掉任何一项后要重做 T6。
## 9. 验收命令分工
| 谁 | 命令 |
| --- | --- |
| Grok 自跑(工作树 `.venv`) | `python -m pytest tests/test_rect_scoring_perf_20261013.py tests/test_rectification_engine_memoization.py tests/test_event_probes_guided_windows.py tests/test_dasha_transition_proximity_cross_midnight.py tests/test_rectification_v5_services.py tests/test_rectification_v5_vedastro_validation.py tests/test_rectification_validation_integrity_gate.py tests/test_sealed_holdout_contract_freshness.py tests/test_reported_offset_research.py tests/test_rectification_confirmation_and.py tests/test_api_server_growth_contract.py -q`;`python -m pytest tests/test_rectification_*.py -q`(BUG-747:改回执路径必须跑全量校正测试);T5 `bench` 与跨版本 `dump` 比较;T6 冻结与逐字节核对;前端单文件 `rectification-confirmation-gate.test.ts`。前端全量、Python 快速门**不跑**,以 Claude 为准 |
| Claude 验收 | 上述全部 + `.venv/bin/python scripts/run_quality_gate.py --profile quick` + Python 全量 pytest 与基线逐名比对(feedback:引擎路径改动不能只看快速门)+ 前端全量 `npm test`(看 fail 与 cancelled)+ 独立跑 T5 跨版本 11 场景 sha256 + 复核 T6 trials 逐字节 + `tests/test_repo_privacy_markers.py` |
| 前端(仅当 T4b 改了 `engine-client.ts`) | Grok:`tsc --noEmit`、`npm run lint`、`rectification-v9-engine-contract.test.ts` 单文件;Claude:前端全量 `npm test` |
| 不需要 | `npm run test:db`(不动数据库) |
## 10. 本单不做(另议)
- 探针路径 `_active_vimshottari(birth_date, …)` 用请求日期而不是候选日期,与打分路径口径不同;本单不碰,只记录。
- `candidate_feature_snapshot` 417 KB 只读 `features[*]` 里三个字段,瘦身要动前端契约,另开单。
- `guided_collect_windows` 与 `merge_transition_proximity` 共用每候选的大运起点表(还能省约 0.3–0.5 s)。
## 11. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rect-scoring-perf-20261013 .worktrees/rect-scoring-perf-20261013 origin/staging
cd .worktrees/rect-scoring-perf-20261013
git log -1 --format=%h # 应为 77057a819 或其后代
git diff --stat 77057a819 origin/staging -- scripts/rectification scripts/active_rectification_event_engine.py scripts/functional_benefics.py scripts/narayana_dasha.py docs/research references/rectification_sealed_holdout.v1.json
# ↑ 若非空:有人改过冻结文件或冻结记录,先回报,§2 数字与 T6 的对照基准要重新取
grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -1 # 核对最大号,写作时为 BUG-1317
grep -n "PRODUCTION_FILES" -A15 scripts/research/sealed_holdout_rerun.py
grep -rn "event_contribution_matrix\|candidate_decision_receipt" tests scripts frontend/tests frontend/src
```
## 12. BUG 编号起点
BUG-1323(引导窗口)、BUG-1324(不变量)、BUG-1325(响应)、BUG-1345(候选数上限,产品 10-14 决定)。开工核对最大号;若已被占用,顺延并在 PROGRESS 写明。
## 13. 交付物清单
- 代码:
- `scripts/rectification/event_probes.py`
- `scripts/rectification/dasha_transition_proximity.py`
- `scripts/active_rectification_event_engine.py`
- `scripts/rectification/api_service.py`
- 读矩阵的研究脚本(显式开关)
- 脚本与测试:
- `scripts/research/rect_scoring_perf_20261013.py`
- `tests/test_rect_scoring_perf_20261013.py`
- `tests/_baseline_rect_scoring_20261013.py`
- 记忆化测试扩展
- 冻结:`docs/research/{sealed_holdout_rerun,reported_offset}_scoring_perf_2026_10_13{.freeze,}.json` 与 §6 T6 的指针更新
- 记录:
- `docs/BUG_HISTORY.md`(BUG-1323~1325)
- `docs/tasks/PROGRESS-rect-scoring-perf-20261013.md`
- `CHANGELOG.md`
- `docs/research/pre_work_error_ledger.md`(ERR-110 追加)
- 不改:`frontend/DESIGN.md`、迁移、workflow、`jyotish_api_server.py`