Compare commits

..
Author SHA1 Message Date
jesse-uxGrok (agent loop)Cursor
4d2506c8c1 fix(reading): give Niryaana Shoola its own PVR table
The report used the same nine-year rule as Shoola. Start from the stronger second or eighth sign and list mahadashas only.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 12:21:35 +08:00
Jesse_Chen b827901c1d merge origin/staging into narrower delivery (只碰文档, issue #77)
Independent Staging Quality Gate / validate (push) Successful in 13m49s
Independent Staging Quality Gate / publish (push) Successful in 7m55s
2026-10-19 11:11:50 +08:00
Jesse_ChenandClaude Opus 5.5 0a760364aa 按产品选 B 把交付区间回放里的钟点改成相对分钟,结论不变(issue #77)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-19 11:11:33 +08:00
Jesse_ChenandClaude Opus 5.5 2ee2b05999 docs(tasks): synastry full report fix brief adds R10 (issue #82)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-19 10:25:33 +08:00
Jesse_ChenandClaude Opus 5.5 3e37271728 docs(tasks): synastry full report fix brief (issue #82)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-19 10:20:57 +08:00
Jesse_ChenandClaude Opus 5.5 7f0c5f93fb docs(tasks): rectification narrower delivery third fix brief (issue #77)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-19 09:21:55 +08:00
Jesse_ChenandClaude Opus 5.5 e7cab24c1a merge origin/staging into narrower delivery (只碰文档;显示漏段 BUG-1375 顺延为 BUG-1392, issue #77)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-19 09:15:55 +08:00
jesse-uxandGrok (agent loop) f67d266129 补上交付区间收窄的两份回放,并把撞号的记录改成 BUG-1375。
两份各 924 条,12 格包含率都过红线。BUG-1370 到 1374 留给婚恋合盘。

Co-Authored-By: Grok (agent loop)
2026-10-19 08:53:18 +08:00
jesse-uxandGrok (agent loop) 1ba473970f 避免四舍五入和缺区间把还可能的时间从目前范围里藏掉。
Co-Authored-By: Grok (agent loop)
2026-10-17 21:16:32 +08:00
jesse-ux 25c51ff0d0 merge origin/staging into narrower delivery (只碰文档, issue #77) 2026-10-17 21:14:32 +08:00
jesse-uxandGrok (agent loop) 56a3c08145 显示生时校正里还活着的时间段,不再把空档涂进目前范围。
交付卡、旁白和遥测在有簇区间时按段书写。打分、出题和进区间门槛未改;对半出题和门槛 6 因为没有真值保留回放而没有交付。

Co-Authored-By: Grok (agent loop)
2026-10-17 03:07:25 +08:00
36 changed files with 56257 additions and 150 deletions
+12
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-10-19 — 完整数据版报告的 Niryaana Shoola 大运表改了
- 这张表不再和 Shoola 相同。起运从第二宫和第八宫里较强的星座开始;奇数星座顺行,偶数星座逆行;动、固、双星座分别是 7、8、9 年。只列主运,子运暂不列出。两宫分不出强弱时,这张表本轮不列出。合盘仍不用它做应期。
## 2026-10-18 — 完整解读补上西占对照和五处缺失模块
- 个人报告里的西占对照层不再因为缺文件而整段空白。它仍只作对照,不拿来改印度占星的结论。
@@ -27,6 +31,14 @@
- 档案列表、标题旁的人名、聊天里的人物选择,人多时在自己的框里上下滚动,不把页面撑出屏幕。
- 生产部署前要先跑这一轮的数据库迁移。Skill 版本不变。
## 2026-10-17 — 生时校正的分段范围不再漏掉还可能的时间
卡片和旁白上的「目前范围」改为读已经算好的分段。差不到 8 分的候选不会因为四舍五入被藏掉。某一段缺少区间数据时,改回显示整段首尾,不再跳过那一段。打分、采用和确认没有改。Skill 版本不变(BUG-1392)。
## 2026-10-16 — 生时校正的交付范围按还活着的时间段显示
问完题之后,卡片和旁白不再把中间已经排除的空档算进「目前范围」。相邻不超过 4 分钟的段会并在一起;超过三段时只写出前三段,并写明一共几段、大约还剩多少分钟。打分、采用和确认没有改。
## 2026-10-16 — 大运接口不再编造周期,Ashtottari 计入出生余额
- 选择一套还没接上引擎的大运时,接口会明确回复未实现,不再返回编出来的周期。已经接上的大运与完整解读使用同一套算法。Vimshottari 的算法和返回字段不变。
+75
View File
@@ -36780,3 +36780,78 @@
- 相关记录:BUG-1389。
- 复发自:无。
- 修复版本:分支 `codex/upstream-sync8-finish-20261018`。
## BUG-1367 | 交付范围按声明窗口的首尾显示,中间空档也被算进去
- 状态:resolved
- 首次发现 / 最近更新:2026-10-16 / 2026-10-16
- 来源:issue #77。任务书 `docs/tasks/TASK-rectification-narrower-delivery-20261016.md`。研究记录里 ±60 的显示宽度中位约 71 分钟、实际剩余中位约 42 分钟。那份 77 例回放本轮没有重跑。
- 现象:问完题后,交付卡上的「目前范围」是一整段首尾。中间已经排除的空档仍看起来像还可能的时间。
- 触发条件:同一声明窗口里有多段还活着的簇,段与段之间的空档大于 4 分钟。`segment_index` 是用户声明的窗口段,不是候选簇。
- 根因:`interval_union_width` 和 `unionWindowIntervals` 把同一声明段里的区间收成最早到最晚。交付卡标题走这条包络。
- 修复:卡片、旁白、口头可说的钟点和遥测宽度改用还活着的簇。落后峰值达到 8 分的候选不进这段显示,和现在的进区间门槛相同。相邻空档不超过 4 分钟的段合并;超过三段时只写前三段,并写一共几段、大约还剩多少分钟。打分、采用、确认、分离和 `MIN_SEPARATION_LEAD` 不动。写盘和读盘仍用同一个 8。会话标题仍由数据库里的单段生成,本轮不改表。
- 验证:`frontend/tests/rectification-living-display-20261016.test.ts` 覆盖单段、空档 4 分钟合并、空档 5 分钟拆开、五段只显示前三段,以及卡片标题不再画出整段包络。原先 `tests/test_rectification_living_display_intervals.py` 也锁同一套合并。BUG-1392 确认技能报告的「不可分宽度」由前端按决策包络填写,Python 没有用户看得到的范围句,删掉了这个没有调用方的文件。合并仍由上面的前端测试锁定。
- 防复发:上述测试。显示函数放在密封打分文件之外,打分路径不引用它。没有簇区间的旧结果仍用原来的首尾。
- 相关记录:BUG-423、BUG-560。
- 复发自:无。
- 修复版本:分支 `codex/rectification-narrower-delivery-20261016`。
## BUG-1368 | 剩余候选大多同侧时题目切不开,题数上限仍是 8
- 状态:blocked
- 首次发现 / 最近更新:2026-10-16 / 2026-10-16
- 来源:issue #77 任务书根因 2。本轮没有新的回放来复核「同侧占比」。
- 现象:后面几题往往排除得很少。可出题数量和轮数上限都停在 8 这一档。
- 触发条件:探针仍按原有顺序供给;`DEFAULT_MAX_DISCRIMINATION_ROUNDS = 8`。
- 根因:任务书指向探针供给上限和选题不按剩余后验对半。本轮没有改这些文件,也没有用新回放重新测量,根因沿用任务书。
- 修复:未改。探针文件在密封打分身份里。对半出题会改变问哪些题,必须先有红线里的真值保留对照。本轮没有跑成 77 例、三个窗口、全对以及分别翻转第 1、3、5 题的生产回放,所以这项撤下。红线没有放宽。
- 验证:轮数上限仍是 8。没有新增选题测试。
- 防复发:在这项真正交付之前,不得把「少数一侧不足两成仍不出题」写成已经生效。
- 相关记录:BUG-1367。
- 复发自:无。
- 修复版本:未修。
## BUG-1369 | 进入交付区间仍要落后峰值 8 分
- 状态:blocked
- 首次发现 / 最近更新:2026-10-16 / 2026-10-16
- 来源:issue #77 任务书根因 3。本轮没有分档回放。
- 现象:一道题通常只拉开约 4 分。候选至少要连续两道题都反向,才会离开交付区间。
- 触发条件:`credible_range`、`credible_intervals` 和还有效的候选都用 `MIN_SEPARATION_LEAD = 8`。
- 根因:任务书写明进区间门槛是 8,而单题分差更小。本轮没有把门槛改成 6,也没有新回放证明改成 6 之后真值仍留在区间里。
- 修复:未新增另一套交付门槛,也没有按窗口回退表。写盘和读盘仍都用 8,避免一边 6、一边 8。分离、采用、确认和盘型段汇总仍用 8。
- 验证:`MIN_SEPARATION_LEAD` 仍是 8。没有「写 6 读 6」的新测试,因为这条路径没有落地。
- 防复发:不得只改写盘或只改读盘。不得用修改 `MIN_SEPARATION_LEAD` 来收窄区间。
- 相关记录:BUG-423、BUG-560、BUG-1367。
- 复发自:无。
- 修复版本:未修。
## BUG-1392 | 分段显示用取整分数,还会跳过缺区间的活候选
- 状态:resolved
- 首次发现 / 最近更新:2026-10-17 / 2026-10-17
- 来源:issue #77 第 1 次验收打回。修复单 `docs/tasks/TASK-rectification-narrower-delivery-fix-20261017.md`。
- 现象:交付卡上的「目前范围」有时比真正还可能的时间更窄。真实出生时间可能落在被藏起来的那段里。
- 触发条件:两处。一是峰值 7.6、另一候选 0.4,实际差 7.2 分,仍该留下;卡片只有取整后的相对支持,变成差 8 分,被排除。二是某个还在范围内的候选没有簇区间,新显示直接跳过它,原来会退回整段首尾。
- 根因:卡片和只读范围行用解析后的候选重算谁还活着。解析结果只保留 0–100 的整数相对支持,不保留原始 posterior_score。缺区间时函数跳过该候选,而不是整段退回。
- 修复:写盘时用 `stillValidCandidates` 的原始 posterior_score 算出分段,写入 `living_display_intervals`。读盘只展示这份区间。没有这份字段,或任一还活着的候选缺区间,就用原来的首尾。门槛仍是 8。`credible_range` 的写盘和读盘都还是这一个 8,没有另起一套。打分、采用、确认、分离和 `MIN_SEPARATION_LEAD` 不动。技能报告里的「不可分宽度」仍由前端按决策包络填写,Python 里没有用户看得到的范围句,因此删掉没有调用方的 `scripts/rectification/living_display_intervals.py`。
- 验证:`frontend/tests/rectification-living-display-20261016.test.ts` 覆盖差 7.2 分的两边都在显示里、一个活候选缺区间则没有分段、显示成员与 `stillValidCandidates` 相同,以及写盘把这两段存下来。没有存过分段的卡片仍显示整段首尾。另用开工 SHA `712a0fd2` 的一条已答完的 ±60 真实回放样本(只留分数和区间)锁定:显示成员与 `stillValidCandidates` 相同,标题段数和剩余分钟与该样本一致,空档大于 4 分钟。
- 防复发:不得再用取整后的相对支持决定显示成员。不得在缺区间时只跳过那一个候选。不得只改写盘或只改读盘。不得用修改 `MIN_SEPARATION_LEAD` 来收窄或放宽显示。
- 相关记录:BUG-423、BUG-1367。
- 复发自:无。
- 修复版本:分支 `codex/rectification-narrower-delivery-20261016`。
## BUG-1393 | Niryaana Shoola 和 Shoola 用了同一套算法,报告里两张表逐行相同
- 状态:resolved
- 首次发现 / 最近更新:2026-10-18 / 2026-10-19
- 来源:issue #84。任务书 `docs/tasks/TASK-niryaana-shoola-pvr-20261018.md`。检索「niryaana_shoola」「goel_nirayana_shoola_v1」,命中 BUG-1283。
- 现象:完整数据版报告里的 Niryaana Shoola 与 Shoola 逐行相同。两张表都从上升和第七宫里取较强的星座起运,每宫 9 年,一律顺行。
- 触发条件:打开带本命盘的完整数据版报告,或读取 full-reading 的 `niryaana_shoola_dasha`。
- 根因:BUG-1283 把报告改走 `goel_nirayana_shoola_v1` 时,所选规则其实是 Shoola 的。当时没有和同文件里的 Shoola 对过输出。本条推翻该 profile。BUG-1283 的另一半不变:新路径仍不得调用旧的 `scripts/niryaana_shoola_dasha.py`。
- 修复:在 `scripts/rashi_dashas_native.py` 内改成 `pvr_niryaana_shoola_v1`。起运比较第二宫和第八宫,依次看宫内九曜个数、星座相位数、有没有入旺行星;三层都相同则本轮不上表。奇数星座顺行,偶数星座逆行。动、固、双星座分别 7、8、9 年。只出主运。full-reading 的 `niryaana_shoola_dasha` 改填这张表。合盘校验器对这一行仍固定为不适用。
- 验证:`tests/test_rashi_dashas_native.py` 在屏蔽 PyJHora 后 18 通过;`tests/test_full_reading_module_parity.py` 与 `tests/test_niryaana_shoola_dasha.py` 合计 10 通过。同一张样例盘上,Niryaana Shoola 与 Shoola 的首运或年数不再全同。语法树断言仍确认新路径不导入旧文件。
- 防复发:上述测试。不得再把 Niryaana Shoola 接回 `goel_nirayana_shoola_v1` 或 `_nine_year`。平手不得默认挑一个星座。不得为这张表编造子运。
- 相关记录:BUG-1283。
- 复发自:无。这是推翻 BUG-1283 所选 profile,不是旧文件被重新调用。
- 修复版本:分支 `codex/niryaana-shoola-pvr-20261018`。
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
# 进度:Niryaana Shoola 改为 PVR 口径(2026-10-19)
- 基线:`origin/staging` `b827901c1`。分支 `codex/niryaana-shoola-pvr-20261018`。
- 前置:#81 已在 staging(`900581068`),issue 标签为 `agent/待真机`。本单在其后开工。
- BUG 编号:开工时最大号 BUG-1392,本单使用 BUG-1393。关联 BUG-1283,并写明推翻其所选 `goel_nirayana_shoola_v1`。旧文件仍不得被新路径调用。
## 做了什么
- `scripts/rashi_dashas_native.py`:Niryaana Shoola 改为 `pvr_niryaana_shoola_v1`。起运比较第二宫和第八宫(九曜个数、星座相位数、入旺行星);奇数星座顺行,偶数星座逆行;动 / 固 / 双星座 7 / 8 / 9 年。只出主运。三层平手则本轮不上表。没有搬上游文件,也没有 import 旧的 `niryaana_shoola_dasha.py`。
- `scripts/jyotish_engine.py`:full-reading 的 `niryaana_shoola_dasha` 改填上面的结果,替换 #81 的占位。
- `scripts/rashi_dasha_report.py`:该节列出主运,并写「子运规则未定,暂不列出。」
- 对照文件里 Niryaana Shoola 的本站行和差异步骤按新表重记。PyJHora 行没有重跑。
## 断言三栏
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `test_rashi_dashas_native.py` 同盘 Niryaana | 首运 Virgo,十二段都是 9 年,有子运 | `pvr_niryaana_shoola_v1`,首运 Aries、顺行,年数从 7/8/9 起,无子运 | 推翻与 Shoola 相同的旧 profile |
| `test_full_reading_module_parity.py` | `execution_status=blocked`,原因「口径更正中」 | `parameter_sensitive`,profile `pvr_niryaana_shoola_v1`,periods 为主运 | #84 用真结果替换占位 |
| fixture `native.niryaana_shoola` 与 `diff_steps` | 与 Shoola 相同的 9 年顺行表 | PVR 主运星座和年数;差异步骤按新表重记 | 只改本站期望,PyJHora 行不动 |
## 命令
工作树没有 `.venv`。下面用已装 Swiss Ephemeris 的 Anaconda 3.11:`C:\Users\74082\anaconda3\python.exe`。
| 命令 | 结果 |
| --- | --- |
| `tests/test_rashi_dashas_native.py`(屏蔽 PyJHora 导入) | 18 通过,退出码 0 |
| `tests/test_full_reading_module_parity.py` + `tests/test_niryaana_shoola_dasha.py` | 10 通过,退出码 0 |
| 全量 pytest、`run_quality_gate.py --profile quick` | 未跑。Windows 分工把这两项留给 Claude 验收 |
## 偏离
- 没有整份搬上游 `niryaana_shoola_dasha.py`。力量比较、方向和年数按上游 `9f08b57b` / `258e08e3` 写在本仓文件里。
- 两位公开名人用仓库里已有的公开盘:一张起射手并顺行,一张起水瓶并顺行,主运序列与该起运下的 7/8/9 年规则一致。
- 平手样例(七曜都在巨蟹、白羊上升)结果为「本轮不上表」。节点占宫、节点星座相位、外行星不计数,与上游 `258e08e3` 的样例一致。
@@ -0,0 +1,74 @@
# 进度:生时校正交付区间收窄 2026-10-16
issue #77。分支 `codex/rectification-narrower-delivery-20261016`。
## 结论
这一轮只交付分段显示(T1)。问完题之后,卡片和旁白按还活着的时间段写「目前范围」,不再把中间已经排除的空档涂成一整段。打分、采用、确认、出题和进区间门槛都没有改。
T0 基线回放没有跑。T2 对半出题、T3 门槛 6、T4 合并回放都没有做。原因:任务书的红线要求先在同一套生产回放上量出「真值还在不在交付区间」。现有回放脚本不记录这一格,也不翻转第 1、3、5 题。没有这组数字就不能改「谁留在区间里」或「问哪一题」。红线没有放宽。
## 基线
- 计划里的 base `712a0fd2a19947405a1dfb486bad96a7661d41c3` 在 `git fetch origin --prune` 之后存在,分支从这里开出。
- 任务书示例里的 `origin/staging` @ `9b7509155` 不是这次开工点。fetch 时 `origin/staging` 已经是 `712a0fd2a19947405a1dfb486bad96a7661d41c3`。
- 研究记录 `docs/research/rectification_width_funnel_2026_10_16.md` 只作背景,不用它代替本轮基线。
## 做了什么
- 前端 `livingDisplayFromCandidates`:还没淘汰、并且离峰值不足 8 分的候选,取出各自的簇区间。空档不超过 4 分钟的相邻段合并;空档 5 分钟保持两段。宽度按每段首尾都算进去的分钟数相加。超过 3 段时,`windowIntervalsLabel` 只写前 3 段,再写「等 N 段,共约 X 分钟」。
- 没有簇区间的旧结果仍用原来的首尾,避免把缺字段收成空白。
- 交付卡标题、盘型交付的分钟范围、采用和中间轮旁白、口头可说的钟点、遥测里的范围宽度,在有簇区间时改用上面的结果。
- Python 侧同一套合并放在 `scripts/rectification/living_display_intervals.py`。没有放进 `candidate_window.py`,因为那个文件在密封打分身份里,改它会改冻结哈希。打分代码不引用这个新文件。
- `frontend/DESIGN.md`、`frontend/docs/VOICE.md`、`CHANGELOG.md` 已同步。
## 口径
| 位置 | 这一轮用的口径 |
| --- | --- |
| `scripts/rectification/candidate_window.py` 的 `interval_union_width` | 决策包络,未改 |
| `scripts/rectification/api_service.py` 的宽度 | 决策包络,未改 |
| `scripts/rectification/decision_policy.py` 的平台宽度 | 决策包络,未改 |
| `credible-range.ts` 的还有效区间 | 决策包络,门槛仍是 8 |
| `windowIntervalsWidth`、精度门、候选平台 | 决策包络,未改 |
| 技能报告里的「不可分宽度」 | 决策包络,未改 |
| 时间轴色带 | 仍是 `credible_range` 的单段首尾 |
| 会话标题 | 仍是数据库函数里的单段。本轮不改表 |
| 扫描块上的钟点标签 | 仍用块自己的区间;段数超过 3 时会走同一句「等 N 段」 |
| 交付卡、盘型范围、旁白、口头钟点、遥测宽度 | 有簇区间时用还活着的段;没有则仍用旧首尾 |
只读范围那一行要等结果里已经有 `credible_range` 才出现。簇完全拆开、旧范围为空时,这行仍不显示;卡片标题仍按活着的段写。
## 没做的
- T0:没有产出 `docs/research/rectification_narrower_delivery_baseline_2026_10_16.json`。
- T2:`DEFAULT_MAX_DISCRIMINATION_ROUNDS` 仍是 8。探针供给、对半选题、少数一侧不足 20% 就不出,都没改。记为 BUG-1368,状态 blocked。
- T3:没有 `DELIVERY_RANGE_LEAD`,没有分档回退表。写盘和读盘仍都是 8。记为 BUG-1369,状态 blocked。
- T4:没有产出 `docs/research/rectification_narrower_delivery_replay_2026_10_16.json`。
- 显示用的候选是虚构的卡片形状,不是一份 ±60 黄金样本。仓库里现成的黄金样本没有「前后 60 分钟、中间有大于 4 分钟空档」的簇,本轮也没有跑出这样一份回放。
- 真机清单写在 `docs/testing/rectification-narrower-delivery-20261016.md`,没有在手机或浏览器里走一遍。
## 检查
| 检查 | 命令 | 结果数字 |
| --- | --- | --- |
| 类型检查 | `frontend` 下 `tsc --noEmit` | 0 错误 |
| lint | `frontend` 下 `npm run lint` | 0 error,123 warning(原有警告,没有顺手改) |
| 显示单测 | 含在下面的前端测试里 | 6 条都通过 |
| Python 显示 | `.venv\Scripts\python.exe -m pytest tests/test_rectification_living_display_intervals.py -q` | 4 passed |
| 隐私标记 | `.venv\Scripts\python.exe -m pytest tests/test_repo_privacy_markers.py -q` | 62 passed |
| 前端测试 | 564 个不含数据库夹具的测试文件,`tsx --test --test-concurrency=8` | TAP 计 3938 条:通过 3893,失败 45,退出码 1 |
| 生产路径 PG 回放 | `varga_resolution_persisted_replay.py`,77 例 × 三档 × {全对,翻第 1/3/5 题} | 未跑 |
| Python 快速门 | `run_quality_gate.py --profile quick` | 未跑 |
| 数据库套件 | `npm run test:db` | 未跑(没改表) |
| 首屏构建 | `frontend` 下 `npm run build` | 退出码 0。`/` 为 Static。首页引用的 29 个 JS 按 gzip-9 合计 570406 字节。没有用改动前的代码在这台机器上再构建一次,所以不写增减百分比 |
现有回放脚本固定全答对、最多 6 轮、两种段顺序都跑,记录的是分盘段是否保住真值,不是「真值在不在交付区间」。一次中途编译失败的单例冒烟不能当作基线,已经作废。
`npm test` 先按全量启动。数据库容器的健康检查一直停在 starting,C 盘当时只剩约 4.2 GB,所以中止了这次全量,并清掉测试容器。随后改跑 564 个不启动数据库夹具的测试文件,跳过 50 个会拉起 PostgreSQL 的文件。45 条失败都不是这次显示改动的断言:4 条是 Windows 上把盘符拼成 `G:\G:\` 后找不到文件;6 条是本机没有 WSL;约 32 条是加载 `tailwind-merge` 时解析失败。其中首页往返那一个文件单独再跑仍是 17 失败、0 通过,同一条解析错误,不是并行才出现;1 条是回答计时期望 `answer_truncated` 实际得到 `empty_answer`;2 条是技能路径用反斜杠,对不上写成正斜杠的断言。本轮 6 条分段显示测试都在通过的 3893 条里。日志没有打出运行器最后的汇总行,上面的数字是按 TAP 的 ok / not ok 行数出来的。
## 偏离
- 任务书让步是先交 T0+T1。T0 没有数字,所以不能声称 ±60 已从约 71 分钟降到约 42 分钟。代码只保证:有空档时卡片不再画包络。
- 会话标题和「不可分宽度」仍是决策包络。标题要改数据库函数,任务书禁止本轮改表。
- 显示成员仍用门槛 8,因为门槛 6 没有交付。这和写盘、读盘保持同一个数。
@@ -0,0 +1,76 @@
# 进度:生时校正交付区间收窄 · 第 1 次打回修复(2026-10-17)
issue #77。分支 `codex/rectification-narrower-delivery-20261016`。修复单在 `origin/staging` 的 `docs/tasks/TASK-rectification-narrower-delivery-fix-20261017.md`。
## 结论
卡片和旁白上的「目前范围」改为读写盘时算好的分段。差 7.2 分、四舍五入后看起来像差 8 分的候选会留下。某个还活着的候选没有时间区间时,整张卡退回原来的首尾,不再把那一段藏起来。打分、采用、确认、分离门槛 8、出题闸 30/45 天都没有改。对半出题和把进区间门槛从 8 改成 6 这次仍然不做。
两份回放都已落地,各 924 条。每档都是 70 条能比、7 条被挡住。全对和翻 1/3/5 的包含率都是 70/70。分段显示没有比改前的整段包络更漏真值,12 格都过红线。退出码是 1,因为有被挡住的行,不是条数不够。
## 口径
| 位置 | 这一轮 |
| --- | --- |
| 交付卡、只读范围行、旁白、口头钟点、遥测宽度 | 读结果里已保存的 `living_display_intervals`。没有这份字段,或写盘时某个还活着的候选缺区间,就用原来的首尾 |
| 谁还活着 | 与 `stillValidCandidates` 相同,用原始 `posterior_score`,门槛仍是 8。不用取整后的相对支持 |
| `credible_range` / `credible_intervals` | 决策包络,门槛仍是 8 |
| 技能报告「不可分宽度」 | 仍由前端按决策包络填写(`candidate-plateau.ts`)。Python 里没有用户看得到的范围句 |
| 会话标题 | 仍是数据库函数里的单段。本轮不改表 |
| 对半出题、门槛 8 改 6 | 不做 |
F4 选 **b**:删掉没有调用方的 `scripts/rectification/living_display_intervals.py` 和 `tests/test_rectification_living_display_intervals.py`。
## 漏段
- 写盘(`buildInferenceState`)用 `livingDisplayForInference` 按原始分数算出分段,随推断状态保存。
- 读盘只展示保存下来的区间。旧结果没有这个字段时,标题仍是原来的首尾。
- 真实样本:开工 SHA `712a0fd2` 上一条已经答过题的 ±60 回放,只留下分数和区间,日期改锚到 2000-01-01。样本有大于 4 分钟的空档。显示成员与 `stillValidCandidates` 相同,标题是 3 段、共 38 分钟。文件:`frontend/tests/fixtures/rectification-living-display-radius60-20261017.json`。手写的 7.2 分边界和缺区间用例仍在,没有放宽。
## 包含率
比较的是:改前包络是否包住真值,改后分段显示是否包住真值。能比的行都是 70。全对要求包含条数不少于基线减 1;翻题要求比例不少于基线减 5 个百分点。两边都是 70,全部通过。
| 窗口 | 答题 | 基线包络 | 改后分段 | 剩余分钟中位数(基线 / 改后) | 显示宽度中位数(基线 / 改后) |
| --- | --- | --- | --- | --- | --- |
| ±10 | 全对 | 70/70 | 70/70 | 10 / 10 | 10 / 10 |
| ±10 | 翻 1 | 70/70 | 70/70 | 11.5 / 11.5 | 12 / 12 |
| ±10 | 翻 3 | 70/70 | 70/70 | 10 / 10 | 11 / 11 |
| ±10 | 翻 5 | 70/70 | 70/70 | 10.5 / 10.5 | 10.5 / 10.5 |
| ±30 | 全对 | 70/70 | 70/70 | 11 / 11 | 15 / 12 |
| ±30 | 翻 1 | 70/70 | 70/70 | 17 / 17 | 29 / 17 |
| ±30 | 翻 3 | 70/70 | 70/70 | 19 / 19 | 27 / 20 |
| ±30 | 翻 5 | 70/70 | 70/70 | 17 / 17 | 28 / 18.5 |
| ±60 | 全对 | 70/70 | 70/70 | 21.5 / 21.5 | 32.5 / 21.5 |
| ±60 | 翻 1 | 70/70 | 70/70 | 34 / 34 | 65.5 / 34 |
| ±60 | 翻 3 | 70/70 | 70/70 | 31 / 31 | 59 / 31 |
| ±60 | 翻 5 | 70/70 | 70/70 | 28 / 28 | 53.5 / 28 |
基线文件 `docs/research/rectification_narrower_delivery_baseline_2026_10_16.json`,代码 `712a0fd2a19947405a1dfb486bad96a7661d41c3`,2026-10-18 18:55 写完。对照文件 `docs/research/rectification_narrower_delivery_t1_2026_10_17.json`,代码 `1ba473970ff58eba4b2a21590feef67a7e2254e8`,2026-10-19 07:54 写完。第一遍对照在启动后约 8 分钟因数据库断开而没有落盘,这一份是重跑的。
## 测试
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 分段显示,含 7.2 分、缺区间、成员一致、真实 ±60 样本 | `node --import tsx --test tests/rectification-living-display-20261016.test.ts` | 13 pass,0 fail,0 cancelled |
| Python 显示单测 | 文件已按 F4-b 删除 | 未跑 |
| Python 快速门 | 本轮不跑 | 未跑 |
| `tsc`、`lint`、前端全量 `npm test` | 修复单没有点名 | 未跑 |
## 红线
未改引擎打分、`SCORE_DELTA`、采用/确认门、`MIN_SEPARATION_LEAD`(仍是 8)、`MIN_BOUNDARY_DAYS=45`、`REFRESH_MIN_BOUNDARY_DAYS=30`。
## 其它
- 这条显示漏段原先写成 BUG-1370。婚恋合盘保留了 BUG-1370 到 BUG-1374,上游第八轮从 BUG-1380 起。历史文件里也还没有 1375。所以改记为 BUG-1375。婚恋合盘那几个号没有动。
- `git fetch` 后 `origin/staging` 为 `edf25918b2f14a4339740be505916ba41e65acb7`(修复单所在提交 `f95eb670` 之后还有 5 个文档提交)。本轮用 merge,不 rebase。
- 临时目录 `G:\Ferti\tmp\replay-temp` 和 `G:\Ferti\tmp\baseline-712a0fd2` 不进仓库。
- 2026-10-19 Claude 验收合并时:staging 上 BUG-1375、BUG-1376 已归 #81(full-reading 补模块),本条显示漏段顺延为 **BUG-1392**(1380~1391 已归上游第八轮)。上文保留当时的编号叙述。
## Claude 验收与转换(2026-10-19,产品选 B)
- 第三次验收(`f67d2661`):回放数字通过。Claude 从两份 JSON 的 924 条记录独立重算,12 格(3 窗口 × 全对 / 翻 1/3/5)改前整段与改后分段包含真值都是 70/70;未完成的 84 条两边是同样 7 个案例。±60 全对显示宽度中位 32.5 → 21.5。
- 未过项:`tests/test_repo_privacy_markers.py` 的 R003 命中两份回放 JSON 里 6 处钟点字符串(修复单 `TASK-rectification-narrower-delivery-fix-20261019.md`)。
- 产品在 issue #77 选 B:由 Claude 转换数据文件。做法:区间端点由 `HH:MM` 改为相对本条 `credible_range` 起点的整数分钟(`display_fixture` 相对其最早起点),跨午夜按模 1440 处理;文件顶层加 `clock_encoding` 说明。不改任何程序。
- 核对:每条记录的 `truth_in_envelope`、`truth_in_display`、`remaining_minutes`、`display_width_minutes` 等结论字段转换前后逐条相等;每条区间总宽度转换前后相等;`living_display_intervals` 宽度之和等于 `display_width_minutes`。转换后两份文件不含任何 `"HH:MM"` 字符串,隐私测试通过。
+2 -2
View File
@@ -36,7 +36,7 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| `TASK-rectification-narrower-delivery-20261016.md` | — | **交付区间收窄**:显示改按活着的簇分段(±60 显示 71→实际 42 分钟)、按剩余候选对半出题并放宽到 12 轮(少数侧 <20% 不出题)、新增 `DELIVERY_RANGE_LEAD=6` 只管进区间(`MIN_SEPARATION_LEAD=8` 不动,写读同口径)。红线:PG 回放真值保留全对 ≥基线−1、答错一题 ≥基线−5pp,不过则该档回 8。证据 `docs/research/rectification_width_funnel_2026_10_16.md`(含象征星区间求交复现=随机)。BUG 1367 起 | 待领取 | — |
| `TASK-rectification-narrower-delivery-20261016.md` | `PROGRESS-rectification-narrower-delivery-fix-20261017.md` | **交付区间收窄**:显示改按活着的簇分段。第 1 次打回只补两处显示漏段(取整差 8 分、缺区间整段退回)。对半出题和门槛 8 改 6 仍不做。基线与对照各 924 条,12 格包含率都过红线。BUG-1367 起,本轮原记 BUG-1370,因与婚恋合盘保留号冲突改为 BUG-1392 | 待验收 | `codex/rectification-narrower-delivery-20261016` |
| —(子代理直接执行,无任务书) | `PROGRESS-bug-1060-d9-double-transit-20260927.md` | **双重过运 D9 目标错配(BUG-1060)**:D9 第 N 宫目标误用 D9 上升、D9 宫主目标误用 D1 度数,改为标签所写的 D9 星座;D1 / CL 层逐字节不变;数据卡 golden 重采集(一张盘跨层结论 9→10);另记 BUG-1061 跨层数字比对(未修) | 已验收(待部署) | `codex/bug-1060-d9-double-transit-20260927`(未推送) |
| —(子代理直接执行,无任务书;产品 2026-09-27 授权) | `PROGRESS-bug-1061-cross-layer-pairing-20260927.md` | **双重过运跨层配对按名字数字比对(BUG-1061)**:改为结构化身份配对(D1 第 N 宫↔D9 第 N 宫;D1 第 N 宫主↔同一颗星的 D9 目标);各层结果与 D1/CL golden 逐字节不变;48 样本跨层条目 31→38;数据卡 golden 重采集(乔布斯 6→9、泰勒 9→10) | 已验收(待部署) | `codex/bug-1061-cross-layer-pairing-20260927`(未推送) |
| `TASK-rectification-fewer-probes-card-20260926.md` | [PROGRESS](PROGRESS-rectification-fewer-probes-card-20260926.md) | **减少无效追问 + 卡片区间为主**:引导补件离线新增达标 0 → 上限 6→2、定向题问完门槛未达直接出卡;卡片区间为主标题、代表分钟副标题,第一二名差距 ≥5 个百分点才显示百分比,否则写「目前区分不开」。不放宽任何置信度。先做 | 已验收(真机清单欠) | `4e6e8d87` + 验收补改 `7203d94e`(区分不开时隐藏「最可能」),deploy-staging run 2933 已部署;回放真值 ±30/±60 19→20、宽度中位 ±1 分钟、提问 11.4→7.8;Skill 10.0.30;真机清单 `docs/testing/rectification-fewer-probes-card-20260926.md` 未走 |
@@ -505,6 +505,6 @@
| `TASK-marriage-match-p1-fix-20261018.md` | #79 | 只存两个 profile 的配对结果;旧历史列表不读新行;并发重复写入改为重读 | 待验收 | `codex/marriage-match-p1-20261017` |
| `TASK-marriage-match-p1-fix-20261018-2.md` | #79 | 胶水挪进子包,根目录文件数回到 446;调用清单改指向 marriage_match.py | 待验收 | `codex/marriage-match-p1-20261017` |
| `TASK-full-reading-module-parity-20261018.md` | #81 | full-reading 补齐上游 5 个模块(西占层文件没搬致永远 blocked、Navamsha / Niryaana 用我们自己的实现填键、Lagna Vargottamamsa / Pushkara / Punarphoo)(BUG-1375 起) | 待执行(可与 #78 并行) | `codex/full-reading-module-parity-20261018` |
| `TASK-niryaana-shoola-pvr-20261018.md` | #84 | Niryaana Shoola 改 PVR 口径(我们的表与 Shoola 逐行相同;推翻 BUG-1283 所选 profile;占星师 7a773d13) | 排队(等 #81) | `codex/niryaana-shoola-pvr-20261018` |
| `TASK-niryaana-shoola-pvr-20261018.md` | `PROGRESS-niryaana-shoola-pvr-20261018.md` | #84 Niryaana Shoola 改 PVR 口径(我们的表与 Shoola 逐行相同;推翻 BUG-1283 所选 profile;占星师 7a773d13) | 待验收 | `codex/niryaana-shoola-pvr-20261018` |
| `TASK-synastry-full-report-20261018.md` | #82 | 婚恋合盘:选人页 + 完整合盘报告(关系画像九节:结论+折叠的分析逻辑+关系时间线+技术依据摘要;英文按需;计费键 synastry.full) | 排队(等 #78、#79、#81) | `codex/synastry-full-report-20261018`;动表 |
| `TASK-synastry-report-chat-20261018.md` | #83 | 从合盘报告某一节进入对话问依据(绑定报告与节、对话里可用术语、只依据已存计算结果并逐条核对、计费照普通对话) | 排队(等 #82) | `codex/synastry-report-chat-20261018`;动表 |
@@ -0,0 +1,42 @@
# TASK · 生时校正交付区间收窄 · 第三份修复单(issue #77)
- 日期:2026-10-19
- 被验提交:`f67d266129393197821c1373d0cfdac4c50f2e1f`(分支 `codex/rectification-narrower-delivery-20261016`)
- 前序:`TASK-rectification-narrower-delivery-fix-20261017.md`(F1–F4);产品 10-17 选 A(等回放跑完再交)
- 状态:同一单再次未过,按循环规则等产品负责人拍板谁来改(选项见 issue #77)
- BUG 编号:不新开号;显示漏段那条合并时由 BUG-1375 顺延为 **BUG-1392**(1375/1376 已归 #81)
## 验收结论(Claude)
| 项 | 结论 | 证据 |
| --- | --- | --- |
| F1 回放 | 数字通过 | Claude 从两份 JSON 的 924 条记录独立重算:12 格(3 窗口 × 全对 / 翻 1/3/5)改前整段与改后分段包含真值均 70/70;两边可比较的是同一批 840 条;未完成的 84 条是同样 7 个案例、同一阶段停住 |
| 显示宽度 | 通过 | ±60 全对中位 32.5 → 21.5;±60 翻 1 题 65.5 → 34 |
| tsc / lint | 通过 | 0 错;0 error |
| **隐私标记** | **未通过** | `tests/test_repo_privacy_markers.py`:`R003` 命中 `docs/research/rectification_narrower_delivery_baseline_2026_10_16.json` 2 处、`..._t1_2026_10_17.json` 4 处 |
AGENTS §8.5:写了实测数字的文档推送前必须跑这条测试。
## H1 · 回放文件不得含与隐私标记相撞的钟点(必做)
两份 JSON 的每条记录以 `HH:MM` 字符串记了 `credible_intervals`、`credible_range`、`living_display_intervals` 等区间端点。其中有端点与 `R003` 规则的值相同。不论是否巧合,提交的文件都不得命中。
改法(推荐):
- 回放脚本输出时,区间端点改记为**相对本例窗口起点的分钟数**(整数),不写钟点字符串;真值也只记相对分钟。`truth_in_envelope`、`truth_in_display`、`remaining_minutes`、`display_width_minutes` 等结论字段不变。
- 已有两份 JSON 可以用一次性转换脚本按同一规则改写,不必重跑回放;转换前后每条记录的结论字段逐条相等(进度里贴核对输出)。
- **不要**把这两个文件加进 `test_repo_privacy_markers.py` 的碰撞白名单(白名单只用于已审核的单点、且需要产品授权)。
验收:
- `python3 -m pytest tests/test_repo_privacy_markers.py -q` 绿。
- 两份 JSON 中不再出现 `"HH:MM"` 形状的区间端点;Claude 重算的 12 格包含率与本单「验收结论」表相同。
## 不要做
- 不改任何前端 / Python 业务代码(上一轮已验收)。
- 不放宽隐私规则。
## 回报
issue #77 回报新 SHA,贴隐私测试结果与转换前后结论字段的核对输出。
@@ -0,0 +1,93 @@
# TASK · 完整合盘报告 · 修复单(issue #82,第 1 次打回)
- 日期:2026-10-19
- 被验提交:`9ea5b3b0c072c6b2491e4067fb2316003cbe74e6`(分支 `codex/synastry-full-report-20261018`)
- 原任务书:`docs/tasks/TASK-synastry-full-report-20261018.md`(第四版为准,下称「原单」)
- 执行方:Grok,同一分支追加提交;`origin/staging` 前进时用 `git merge origin/staging`,不 rebase
- BUG 编号:执行中新发现的缺陷,从开工时 `docs/BUG_HISTORY.md` 最大号 +1 起(当前 staging 已用到 1391;#77 合并时会占 1392)
## 验收结论(Claude)
骨架(计费键、迁移、选人、建单、复用、扣点结算)方向对,迁移对已部署代码向后兼容;但**报告正文这条主链在真实数据下跑不通**,且原单要求的测试大部分缺失。打回。
已确认通过(不用再动):
- 上游 Skill 6 个文件与 `docs/research/upstream_full_synastry_7a773d13/skill/` 逐字一致,其余仍与 bde5d615 一致(原单 T9 授权)。
- 迁移 `20261019010000_synastry_full_report.sql`:新列可空、约束只放宽、`chart_subject_usage` 同事务重建且保留旧列、`delete_chart_subject` 仍 `security definer` + `search_path=''`;`synastry_english_jobs` 开了 RLS。
- 计费:`synastry.full` 进了各处枚举 / 管理端 / 标签 / 会员;未发布价格返回 `pricing_configuration_unavailable`;同一对人 + 指纹 + 规则版本命中 ready / generating 时不预留;入队前失败会释放;英文版不预留。
- 增长合同:主 API 文件只在既有分发里加 7 行;`page.tsx` 未动;scripts 根目录未增文件。
## 必须修(阻断)
### R1 · 第 2–7 节拿不到证据
`frontend/src/lib/synastry-full-sections.ts` 的 `SECTION_FIELDS` 把 `emotional_safety`、`communication_conflict_and_repair`、`attraction_and_closeness`、`shared_life_gains_and_pressures`、`relationship_progression`、`can_it_stay_steady` 当成证据包**顶层键**去取(`if (field in packet)`)。它们其实是 `reader_section_brief` 列表里各项的 `section` 值(上游 `build_full_synastry_dossier.py` 的 `READER_SECTION_BRIEFS`,约第 241 行起;组装约第 748 行)。结果是情感、沟通、冲突、日常、方向几节只拿到空对象。
另外 `PRIVATE_KEYS = /birth|date|time|lat|lon|…|day|hour|minute/i` 按子串删键,会误删 `relationship_axis`(含 lat)、`update…`(含 date)一类正常键。
修法:按上游结构取证——从 `reader_section_brief` 里按 `section` 找到对应条目,再按条目里引用的 lens / topic 去 `relationship_lenses`、`pair_interaction_evidence` 等取原始条目。隐私剔除改为**精确键名白名单或黑名单**(出生年月日时分、经纬度、时区、地点),不用子串正则。
验收:用 **两位公开名人**的真实证据包(`tests/test_synastry_full_dossier.py` 已用的那对)生成每节输入,单测断言 9 节输入都非空,且不含任何出生资料键。
### R2 · 提示词与质检对不上,也没按原单写
- 质检 `scripts/marriage_match_service/synastry_full_reader_gate.py` 要求 `label` 必须是 6 个上游标签之一、每节不少于 120 字、全文不少于 1800 字、`evidence` 逐字引用;提示词(`synastry-full-sections.ts`、`mastra/synastry-report.ts`)一条都没告诉模型。
- 原单要求系统提示以关系语言指南(`docs/research/synastry_relationship_language_guide_2026_10_18.md`)为主体,含五条规则与示例,加上游证据规则(每个判断两条支撑、冲突降级、应期写条件、不点名补充方法)。现在只有 5 行,没有示例。
修法:按原单重写提示词,把质检的每条硬规则写进去;质检与提示词共用一份规则常量(或在测试里断言两边一致)。
验收:**真实模型跑一次**两位公开名人,中英各一份,质检一次或两次内通过;进度里贴每节字数、轮次、未通过原因。没有模型 key 就写进 `BLOCKED.md` 并在回报里明说,不得写「通过」。
### R3 · 英文版把中文时间线喂给模型
`synastry-full-run.ts` 英文任务把 `zh.timeline`(中文阶段名、描述、依据)当 `segments` 传入,`sectionEvidence` 再把它塞进英文时间线的输入。原单 T6:英文按需生成,依据行由程序生成。修法:英文时间线的 `segments` 由程序从同一份大运数据重新生成英文依据行;模型只写英文阶段名和描述。加测试:英文时间线输入里不含中日韩字符。
### R4 · 英文任务会永久卡住
`runIdleSynastryEnglish`:先把任务改成 `running`,之后几处 `return null`(找不到报告、快照缺字段)都不回写状态,任务永远停在 `running`;表里有 `lease_token` / `lease_expires_at` 却没用。英文路由把 `failed` 改回 `queued` 时不清 `attempt_count`,超过 `max_attempts` 后认领会撞 `attempt_count <= max_attempts` 的约束,任务永远排队。
修法:认领时写租约,过期可被重新认领;所有提前退出路径都回写 `failed` + 错误码;重排时按原单规则重置次数(或明确上限后给出不可再试的提示)。加测试覆盖这三条。
### R5 · 英文生成的用量没记账
原单红线 5:模型用量照常记录并计入成本统计。英文生成路径没有写用量。补上,与中文路径同一个记账函数,并加测试。
### R6 · 单节重试没做
原单 T4:某一节超时或报错只重写这一节。现在 `writeOne` 抛错会冲出并发池,变成整单 `model_unavailable` 重跑 9 节。修法:单节失败按原单次数重试该节,仍失败才整单失败并退点。加测试。
### R7 · 原单要求的测试大量缺失(AGENTS §7.4:合同 fixture 用真实引擎输出)
- 现有 `synastry-full-report.test.ts`、`tests/test_synastry_full_reader_gate.py` 全是手造「甲 / 乙」数据。补公开名人的真实证据包 golden,并核对技术依据第 ⑥ 块与 `execution_receipt` 逐行一致。
- T1/T2 `npm run test:db`:同一对人复用、指纹变化不复用、删人级联、预留 / 结算 / 释放三条路径。
- T3 路由:同一人 400、他人 id 拒绝、复用不预留、产品开关关闭。
- T4:两轮质检不过 → 退点;生成中删除 → 退点。
- T6 路由与 worker;T7 阅读页渲染;英文质检用例补齐到原单列的各项。
### R8 · T9 上游测试没搬
`test_bilingual_synastry_delivery`、`test_compose_full_synastry_delivery`、`test_full_synastry_dossier`、`test_full_synastry_execution_contract`、`test_prepare_full_synastry_packets` 按原单搬进 `tests/` 并通过。`scripts/aspects.py` 不在快照里,按原单写进 PROGRESS / BLOCKED,不要自写。
### R9 · 前端全量测试新增 1 条失败
`frontend/tests/class-name-definition-contract.test.ts`「every project class a component renders has a rule in our CSS」:`synastry-report-heading`、`synastry-technical`(`src/components/synastry/synastry-report-view.tsx`)在 CSS 里没有规则。补样式或去掉类名;不要加进 `knownUnstyled` 来绕过。Claude 本机:tsc 0 错、lint 0 error、前端 5042 条中失败 26,与基线相比只多这一条(另一条是只在 Docker 下能跑的已知项)。
### R10 · Python 全量测试新增 1 条失败
`tests/test_api_server_security.py::test_capability_audit_scans_registry_and_local_sources`:页面扫描结果多了 `synastry`(新页面 `app/(app)/synastry`),与测试里固定的页面清单不一致(`At index 33 diff: 'synastry' != 'terms'`)。按 AGENTS §7.3 / §7.8 更新期望清单并写「原值 / 新值 / 原因」三栏;不要删掉这条断言。Claude 本机全量 pytest 与 staging 基线比对,只多这一条(`test_vedastro_gateway` 的差异是已知并行抖动)。
## 应该修(不阻断,但这一轮一起做)
- 质检:`evidence in json.dumps(input)` 一个字也能过,改为要求证据是输入里某个**完整字段值**;依据行按原单逐字段比对(星座、星宿也要比);`\bD\d{1,2}\b` 在中文旁失效;时间线 `phase` 也要过禁词;英文禁表补 kuja、nadi、lagna、UL、DK 等。
- 技术依据第 ② 块补齐 7 宫主、DK、UL、A7、D9;第 ⑤ 块用上 `raman_marriage_match`(#79 已存);未知状态不要一律写「无法计算」。
- `synastry_report_id` 始终为空:Python 侧返回 #79 存的那一行 id 并写入。
- 先结算、再标 ready(或标 ready 与结算同事务),避免进程在两步之间崩溃后预留一直挂着。
- 英文生成不要放在 `tick()` 里同步跑,别挡住新报告任务。
- 选人页按原单:自定义下拉、按钮文案「开始合盘」、`synastry.full` 价格说明、「之前的合盘」列表;资料不全的人不可选。
- 阅读页:标题带「·」、支持 `?lang=en`、`personal-report-progress.ts` 补新阶段文案。
- 报告列表:两人都是他人时,两个人的列表里都要能看到。
- 侧栏「婚恋合盘」入口与 `/synastry` 页按 `compatibility` 开关隐藏;英文路由也检查开关。
## 交回前必跑
`tsc`、`lint`、前端全量 `npm test`(与基线逐名比对)、`npm run test:db`(Windows 已知失败清单逐条对照)、`npm run build`(`/` 仍 Static)、全量 `pytest` 与开工时 `origin/staging` 逐名比对、快速门、屏蔽 PyJHora 后跑证据包与质检测试。
@@ -0,0 +1,15 @@
# 真机清单:交付范围按还活着的段显示(2026-10-16)
这一版只改「目前范围」怎么写。题数仍是原来的上限,进区间的门槛仍是 8 分。不要把任何人的出生日期、钟点或姓名写进这份清单、进度或聊天记录。
在一台已登录的测试账号上,用前后各 30 分钟的窗口走完一次校正:
1. 问完这一轮会出的选择题,直到出现范围卡片。
2. 看卡片标题是一段,还是用顿号分开的几段。
3. 若是两段或三段,旁白里应能看到「剩余约 X 分钟,分 N 段」。不应出现「定到分钟」。
4. 若超过三段,标题只写最前面的三段,并写一共几段、大约还剩多少分钟。
5. 中间已经空出来的间隔,不应再被画成一整段连续时间。
6. 点「更像这个」之后,排盘用的是选中的那一分钟,不是把整段都确认掉。
7. 这一版不会因为门槛改成 6 而少留人,也不会多问到 12 题。若题数或收窄幅度和改版前一样,这是预期。
本轮没有在真机上执行这份清单。
+1 -1
View File
@@ -1025,7 +1025,7 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
**相同的句子只写一次。** 两列或三列共有的 D9 / D10 / 月宿性格句提到卡片顶部(「三个时间的事业盘都在巨蟹座:做事以照顾人为主」)。列内只留有差别的句子;某列没有差别句就不显示性格块,不写「同上」。单列不提升共享句。
**卡头以范围为主(D3,2026-09-26)。** 主标题仍是「目前范围 HH:MM–HH:MM(对照了 N 件经历)」(`<strong>`,`--type-title-sm`),紧跟一行副标题「最可能 HH:MM」(`.rectification-range-delivery__most-likely`,`--type-body-md`、次级墨色、等宽数字),取 `range_delivery.representative_time`。副标题只是代表分钟的人话,不是确认;卡底代表分钟边界句照旧。各列不写百分比(第一名领先不足 5 个百分点)时副标题也不出,免得「最可能」与「区分不开」并排自相矛盾。
**卡头以范围为主(D3,2026-09-26;2026-10-16 起按活着的段显示)。** 主标题仍是「目前范围 …(对照了 N 件经历)」(`<strong>`,`--type-title-sm`)。范围不再把同一声明窗口里已经空出来的间隔涂成一整段:只画出还活着的簇,相邻空档不超过 4 分钟的并成一段;超过三段时只写最前面的三段,再写「等 N 段,共约 X 分钟」。没有簇区间的旧结果仍用原来的首尾。2026-10-17 起,分段不在卡片上用四舍五入后的分数重算:写盘时按原始分数决定谁还在,区间随结果保存;旧结果没有这份区间时,标题仍用原来的首尾。某个还在范围内的候选缺少区间时,整张卡退回首尾,不把缺数据的那一段藏起来。紧跟一行副标题「最可能 HH:MM」(`.rectification-range-delivery__most-likely`,`--type-body-md`、次级墨色、等宽数字),取 `range_delivery.representative_time`。副标题只是代表分钟的人话,不是确认,也不承诺定到分钟;卡底代表分钟边界句照旧。各列不写百分比(第一名领先不足 5 个百分点)时副标题也不出,免得「最可能」与「区分不开」并排自相矛盾。
**百分比只在差距明显时出现(D4,2026-09-26)。** 前两列「相对可能性」相差 ≥ 5 个百分点(`RANGE_DELIVERY_PERCENT_MIN_GAP`)时每列照旧写「相对可能性 N%」;否则三列都不写数字,卡上(共享性格句之上)一句「这几个时刻按现在的方法区分不开。」(2026-09-29 起不再邀请补经历,BUG-1084)(`.rectification-range-delivery__indistinct`,`--type-body-md`、正文墨色)。只有一列时没有第二名可比,不写数字也不写这句。排序、列数、性格 / 经历 / 未来窗三行不变。理由:每件事约 11.5 分是窗口内恒定项、约 2.1 分随分钟变,比例分数天然挤在一起(26% / 26% / 20%),差距不明显的数字只会被读成「分出了高下」。
+1 -1
View File
@@ -327,7 +327,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 当前可信区间是 05:00–05:07,代表分钟 05:00。代表分钟只是代表性候选,不是已确认的唯一出生分钟。 | 已经从最初的 30 分钟收到 05:00–05:07 这 7 分钟。代表分钟是 05:00,这只是代表性候选,不是已确认的唯一出生分钟。 | 同一边界语义,带进度,少法务腔。 |
| 剩下的题分不开当前候选。更站得住的范围是 05:15,代表分钟 05:15。 | 再问下去也分不出更准的时间了。眼下更站得住的是 05:15。 | 单分钟不要把范围和代表分钟念两遍;「当前候选」是内部词。 |
| 可以从下面的时间里选一个采用。 / 下面的时间可以先用着。 | 你说的这些经历我都对照过了,下面是这次的结果。(2026-10-07 前为「我按你说的经历认真分析过了,……」,BUG-1258) | 这是认真分析后的结果,不是随便先用着;采用仍不是确认。区间交付卡必须就在这句下面。 |
| 相对支持度 16 · 采用此时间 | 卡头主标题是范围「目前范围 05:00–05:15(对照了 N 件经历)」,副标题「最可能 HH:MM」(代表分钟,只在写百分比时出现;区分不开时不写)。一行至多三列。相同性格句只写一次。每列:有差别的性格句、人话经历对照、下一个时段,点「更像这个」;第一名比第二名高 5 个百分点及以上时,每列再写「相对可能性 N%」。缺数据写灰字「这一分钟还没对照」。不预标「排盘用」。 | 交付对象是范围和区间里并排的候选分钟,不是支持度数字。 |
| 相对支持度 16 · 采用此时间 | 卡头主标题是范围「目前范围 05:00–05:15(对照了 N 件经历)」,副标题「最可能 HH:MM」(代表分钟,只在写百分比时出现;区分不开时不写)。同一窗口里已经空出来的间隔不要涂成一整段:写成还活着的几段(相邻空档不超过 4 分钟的并成一段);超过三段时只写前三段,再写「等 N 段,共约 X 分钟」。两三段时旁白可以补「剩余约 X 分钟,分 N 段」,不承诺定到分钟。一行至多三列。相同性格句只写一次。每列:有差别的性格句、人话经历对照、下一个时段,点「更像这个」;第一名比第二名高 5 个百分点及以上时,每列再写「相对可能性 N%」。缺数据写灰字「这一分钟还没对照」。不预标「排盘用」。 | 交付对象是范围和区间里并排的候选分钟,不是支持度数字。中间空档不是还可能的出生时间。 |
| 相对可能性 26% / 26% / 20%(前两名差不到 5 个百分点) | 不写任何数字,卡上一句:「这几个时刻按现在的方法区分不开。」(2026-09-29 起,原句「……补一件带年月的经历能帮助分开。」作废,BUG-1084) | 差距不明显的百分比只会让人误以为分出了高下(2026-09-26 产品决定,取代 09-08「三列都写相对可能性」的定稿)。排序与三列内容不变。 |
| 已答6轮,再问下去也分不开04:53和05:00,没有年份的分盘题也不再问……两者相对支持度都是16。 | 再问下去也分不开 04:53 和 05:00。这两个时间按现有信息分不开。范围和代表分钟照常说。 | 旁白不得念内部计分词。卡上的数字按上一行的 5 个百分点规则显示,旁白不念百分比。 |
| 候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。 | 每列用服务器拼好的一行时段;没有窗就写「未来一年没有明显的时段」;没算出来写「这一分钟还没对照」。 | 窗口按候选分钟各算,模型不写。不得把「没算」说成「没有窗」。 |
@@ -7,6 +7,7 @@ import { createPortal } from "react-dom";
import type { ChatMessage } from "@/lib/chat-message-view";
import { canShowRectificationSelectionCards } from "@/lib/rectification-candidate-result";
import { RectificationRangeDelivery } from "@/components/rectification-range-delivery";
import { livingDisplayFromReceipt } from "@/lib/rectification-agentic/core/candidate-window";
import {
RECTIFICATION_EMPTY_ACTION_LABEL,
RECTIFICATION_EMPTY_COPY,
@@ -610,6 +611,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{showReadonlyRange && message.renderKey === latestSettledAssistant?.renderKey && candidateResult?.credibleRange && (
<RectificationReadonlyRange
range={candidateResult.credibleRange}
displayLabel={livingDisplayFromReceipt(candidateResult.decisionReceipt, candidateResult.declaredBirthDate)?.label ?? null}
sessionOutcome={candidateResult.sessionOutcome}
discriminatingEventProbes={candidateResult.decisionReceipt?.discriminating_event_probes}
contrastProbes={contrastProbesFromReceipt(candidateResult.decisionReceipt)}
@@ -22,11 +22,13 @@ import { RectificationChoiceCard } from "./rectification-choice-card";
export function RectificationReadonlyRange({
range,
displayLabel,
sessionOutcome,
discriminatingEventProbes,
contrastProbes,
}: Readonly<{
range: readonly [string, string];
displayLabel?: string | null;
sessionOutcome: string | null;
discriminatingEventProbes: unknown;
contrastProbes: unknown;
@@ -35,6 +37,7 @@ export function RectificationReadonlyRange({
<p className="rectification-readonly-range" role="status">
{rectificationReadonlyRangeCopy({
range,
displayLabel,
sessionOutcome,
discriminatingEventProbes,
contrastProbes,
@@ -1,6 +1,6 @@
"use client";
import { windowIntervalsLabel } from "@/lib/rectification-agentic/core/candidate-window";
import { livingDisplayFromReceipt, windowIntervalsLabel } from "@/lib/rectification-agentic/core/candidate-window";
import type { RectificationCandidateResult } from "@/lib/rectification-candidate-result";
import {
RECTIFICATION_USER_COPY,
@@ -44,7 +44,10 @@ export function RectificationRangeDelivery({
const busy = Boolean(acceptingCandidateId) || readonly || adoptLocked;
const adoptAllowed = sessionOutcomeAllowsAdopt(result.sessionOutcome) && result.canAdopt === true;
const columns = delivery?.columns ?? [];
const rangeLabel = result.credibleIntervals?.length ? windowIntervalsLabel(result.credibleIntervals, result.declaredBirthDate)
const living = livingDisplayFromReceipt(result.decisionReceipt, result.declaredBirthDate);
const rangeLabel = living
? living.label
: result.credibleIntervals?.length ? windowIntervalsLabel(result.credibleIntervals, result.declaredBirthDate)
: range ? `${range[0]}–${range[1]}` : null;
const title = rangeLabel
? eventCount > 0
@@ -3,7 +3,7 @@ import type { TransitionSignLookup } from "./sign-from-transitions.ts";
import { clusterRangeFor, clusterEquivalentCandidates } from "./cluster-candidates.ts";
import { evaluateConvergence, holdoutStillRanksFirst, rankActive } from "./convergence-evaluator.ts";
import { warnRepresentativeTimeInconsistency } from "./representative-time-guard.ts";
import { rangeFromTimes, unionStillValidRange, unionStillValidIntervals } from "./credible-range.ts";
import { livingDisplayForInference, rangeFromTimes, unionStillValidRange, unionStillValidIntervals } from "./credible-range.ts";
import { candidatePositionFields, type CandidatePosition } from "./candidate-window.ts";
import { candidateSetId } from "./candidate-set-id.ts";
import { entropyFromScores, normalizeScores } from "./entropy.ts";
@@ -265,17 +265,20 @@ export function buildInferenceState(input: {
winnerId: lastRound?.winner_id,
scoresAfter: lastRound?.scores_after,
});
const deliveredCandidates = candidates.map((item) => (
decision.result_status === "converged" && item.id === decision.winner_id
? { ...item, status: "winner" as const }
: item
));
const livingDisplay = livingDisplayForInference(deliveredCandidates);
return {
...draft,
phase: phaseFor(decision.result_status, draft.phase),
result_status: decision.result_status,
representative_time: decision.representative_time,
credible_range: deliveredRange,
candidates: candidates.map((item) => (
decision.result_status === "converged" && item.id === decision.winner_id
? { ...item, status: "winner" }
: item
)),
candidates: deliveredCandidates,
living_display_intervals: livingDisplay?.intervals ?? null,
};
}
@@ -1,3 +1,5 @@
import { MIN_SEPARATION_LEAD } from "./candidate-separation.ts";
/** Civil date metadata belongs to a result, never reconstructed for historical rows. */
export type CandidateInterval = Readonly<{ start_at: string; end_at: string }>;
export type WindowInterval = CandidateInterval & Readonly<{
@@ -111,6 +113,183 @@ export function requestCandidateIntervals(
return [{ start_at: at(date, startOffset), end_at: at(date, startOffset + width) }];
}
/**
* Display-only merge. Adjacent living clusters whose gap is at most this many
* minutes become one segment. Decision envelopes (`unionWindowIntervals`,
* `interval_union_width`) stay on the declared-segment span.
*/
export const LIVING_DISPLAY_MERGE_GAP_MINUTES = 4;
/** After the merge, the label writes at most this many segments, then a count. */
export const LIVING_DISPLAY_MAX_SEGMENTS = 3;
export type LivingClusterSource = Readonly<{
status?: string;
posterior_score?: number;
relativeSupport?: number;
cluster_intervals?: readonly CandidateInterval[] | null;
clusterIntervals?: readonly CandidateInterval[] | null;
}>;
export type LivingDisplay = Readonly<{
intervals: readonly CandidateInterval[];
label: string;
widthMinutes: number;
segmentCount: number;
}>;
function stampMs(value: string): number {
return Date.parse(`${value}:00Z`);
}
/** Inclusive clock minutes of the rows as given. Does not merge. */
export function inclusiveIntervalMinutes(rows: readonly CandidateInterval[]): number {
return rows.reduce((sum, row) => {
const width = (stampMs(row.end_at) - stampMs(row.start_at)) / 60000 + 1;
return Number.isFinite(width) && width > 0 ? sum + width : sum;
}, 0);
}
/**
* Merge living cluster intervals for display and telemetry only.
* Gap is the number of clock minutes strictly between the two intervals.
* A gap of 4 merges; a gap of 5 stays two segments. Overlaps merge.
*/
export function mergeLivingDisplayIntervals(
rows: readonly CandidateInterval[],
gapMinutes = LIVING_DISPLAY_MERGE_GAP_MINUTES,
): readonly CandidateInterval[] {
const sorted = rows
.filter((row) => validLocalMinute(row.start_at) && validLocalMinute(row.end_at) && row.start_at <= row.end_at)
.map((row) => ({ start_at: row.start_at, end_at: row.end_at }))
.sort((left, right) => left.start_at < right.start_at ? -1 : left.start_at > right.start_at ? 1 : (left.end_at < right.end_at ? -1 : 1));
const merged: CandidateInterval[] = [];
for (const row of sorted) {
const previous = merged.at(-1);
if (!previous) {
merged.push(row);
continue;
}
const gap = (stampMs(row.start_at) - stampMs(previous.end_at)) / 60000 - 1;
if (gap <= gapMinutes) {
if (row.end_at > previous.end_at) merged[merged.length - 1] = { start_at: previous.start_at, end_at: row.end_at };
} else {
merged.push(row);
}
}
return merged;
}
function clusterScore(row: LivingClusterSource): number | null {
if (typeof row.posterior_score === "number" && Number.isFinite(row.posterior_score)) return row.posterior_score;
return null;
}
/**
* Legacy recompute. Delivery reads stored intervals instead.
* Parsed cards only have rounded relativeSupport, which this function ignores.
* Membership for a new result is livingDisplayForInference.
* Returns null when a living candidate has no cluster interval, or when a
* score is missing, so callers keep the envelope title.
*/
export function livingDisplayFromCandidates(
candidates: readonly LivingClusterSource[] | null | undefined,
birthDate?: string | null,
lead = MIN_SEPARATION_LEAD,
): LivingDisplay | null {
if (!candidates?.length) return null;
const active = candidates.filter((row) => row.status !== "eliminated");
if (!active.length || active.some((row) => clusterScore(row) === null)) return null;
const scored = active.map((row) => ({ row, score: clusterScore(row)! }));
const peak = Math.max(...scored.map((item) => item.score));
const living = scored.filter((item) => peak - item.score < lead);
if (!living.length) return null;
if (living.some((item) => !(item.row.cluster_intervals ?? item.row.clusterIntervals)?.length)) return null;
const intervals: CandidateInterval[] = [];
for (const item of living) {
const parts = item.row.cluster_intervals ?? item.row.clusterIntervals ?? [];
for (const part of parts) {
if (part?.start_at && part?.end_at) intervals.push({ start_at: String(part.start_at), end_at: String(part.end_at) });
}
}
if (!intervals.length) return null;
const merged = mergeLivingDisplayIntervals(intervals);
if (!merged.length) return null;
return {
intervals: merged,
label: windowIntervalsLabel(merged, birthDate),
widthMinutes: inclusiveIntervalMinutes(merged),
segmentCount: merged.length,
};
}
/** Stored write-time segments. Invalid or empty values fall back to the envelope title. */
export function parseLivingDisplayIntervals(value: unknown): readonly CandidateInterval[] | null {
if (!Array.isArray(value) || value.length === 0) return null;
const rows: CandidateInterval[] = [];
for (const raw of value) {
if (!raw || typeof raw !== "object") return null;
const start = (raw as { start_at?: unknown }).start_at;
const end = (raw as { end_at?: unknown }).end_at;
if (!validLocalMinute(start) || !validLocalMinute(end) || start > end) return null;
rows.push({ start_at: start, end_at: end });
}
return rows;
}
export function livingDisplayFromStoredIntervals(
value: unknown,
birthDate?: string | null,
): LivingDisplay | null {
const intervals = parseLivingDisplayIntervals(value);
if (!intervals) return null;
return {
intervals,
label: windowIntervalsLabel(intervals, birthDate),
widthMinutes: inclusiveIntervalMinutes(intervals),
segmentCount: intervals.length,
};
}
export function livingDisplayNarrationFromStored(
value: unknown,
birthDate?: string | null,
): { displayLabel: string; displayWidthMinutes: number; displaySegmentCount: number } | null {
const display = livingDisplayFromStoredIntervals(value, birthDate);
if (!display) return null;
return {
displayLabel: display.label,
displayWidthMinutes: display.widthMinutes,
displaySegmentCount: display.segmentCount,
};
}
export function livingDisplayFromReceipt(
receipt: Readonly<Record<string, unknown>> | null | undefined,
birthDate?: string | null,
): LivingDisplay | null {
const state = receipt?.inference_state;
if (!state || typeof state !== "object" || Array.isArray(state)) return null;
return livingDisplayFromStoredIntervals(
(state as { living_display_intervals?: unknown }).living_display_intervals,
birthDate,
);
}
/** Fields to spread into range narration. Null when there is nothing to switch. */
export function livingDisplayNarration(
candidates: readonly LivingClusterSource[] | null | undefined,
birthDate?: string | null,
lead = MIN_SEPARATION_LEAD,
): { displayLabel: string; displayWidthMinutes: number; displaySegmentCount: number } | null {
const display = livingDisplayFromCandidates(candidates, birthDate, lead);
if (!display) return null;
return {
displayLabel: display.label,
displayWidthMinutes: display.widthMinutes,
displaySegmentCount: display.segmentCount,
};
}
/** Within each declared segment retain historical envelope semantics, but never bridge segments. */
export function unionWindowIntervals(rows: readonly WindowInterval[]): readonly WindowInterval[] {
const groups = new Map<number, WindowInterval>();
@@ -137,7 +316,10 @@ export function windowIntervalsLabel(rows: readonly CandidateInterval[], birthDa
if (date === birthDate) return clock;
return `${date < birthDate ? "前一天" : "后一天"} ${clock}`;
};
return rows.map((row) => `${label(row.start_at)}–${label(row.end_at)}`).join("、");
const text = (row: CandidateInterval) => `${label(row.start_at)}–${label(row.end_at)}`;
if (rows.length <= LIVING_DISPLAY_MAX_SEGMENTS) return rows.map(text).join("、");
const shown = rows.slice(0, LIVING_DISPLAY_MAX_SEGMENTS).map(text).join("、");
return `${shown},等 ${rows.length} 段,共约 ${inclusiveIntervalMinutes(rows)} 分钟`;
}
function validLocalMinute(value: unknown): value is string {
@@ -1,6 +1,13 @@
import { MIN_SEPARATION_LEAD } from "./candidate-separation.ts";
import type { InferenceCandidate } from "./types.ts";
import { unionWindowIntervals, type WindowInterval } from "./candidate-window.ts";
import {
inclusiveIntervalMinutes,
mergeLivingDisplayIntervals,
unionWindowIntervals,
windowIntervalsLabel,
type LivingDisplay,
type WindowInterval,
} from "./candidate-window.ts";
export function unionStillValidIntervals(candidates: readonly InferenceCandidate[], lead = MIN_SEPARATION_LEAD): readonly WindowInterval[] {
const active = rankActive(candidates);
@@ -66,6 +73,33 @@ export function unionStillValidRange(
return [fromMinutes(minutes[0]!), fromMinutes(minutes[minutes.length - 1]!)];
}
export type LivingDisplayDecision = LivingDisplay & Readonly<{ memberIds: readonly string[] }>;
/**
* Display membership is stillValidCandidates on raw posterior_score.
* A living candidate with no cluster intervals drops the whole segmented
* title so callers keep the envelope. Rounded relativeSupport is not a score.
*/
export function livingDisplayForInference(
candidates: readonly InferenceCandidate[] | null | undefined,
birthDate?: string | null,
lead = MIN_SEPARATION_LEAD,
): LivingDisplayDecision | null {
if (!candidates?.length) return null;
const living = stillValidCandidates(candidates, lead);
if (!living.length) return null;
if (living.some((row) => !row.cluster_intervals?.length)) return null;
const merged = mergeLivingDisplayIntervals(living.flatMap((row) => row.cluster_intervals ?? []));
if (!merged.length) return null;
return {
intervals: merged,
label: windowIntervalsLabel(merged, birthDate),
widthMinutes: inclusiveIntervalMinutes(merged),
segmentCount: merged.length,
memberIds: living.map((row) => row.id),
};
}
export function rangeFromTimes(times: readonly string[]): readonly [string, string] | null {
const minutes = times
.map(toMinutes)
@@ -62,7 +62,7 @@ export const PROBE_WEIGHT = {
yearless: 0.5,
} as const;
import type { CandidatePosition, WindowInterval } from "./candidate-window.ts";
import type { CandidateInterval, CandidatePosition, WindowInterval } from "./candidate-window.ts";
import type { AscendantSignature, SegmentMinute, SegmentSummary, TargetChart } from "./segment-summary.ts";
export type InferenceCandidate = CandidatePosition & Readonly<{
@@ -169,6 +169,12 @@ export type InferenceState = Readonly<{
representative_time: string | null;
credible_range: readonly [string, string] | null;
credible_intervals?: readonly WindowInterval[];
/**
* Segments shown as 「目前范围」. Written from still-valid candidates using
* raw posterior_score, the same lead as credible_range. Null or absent:
* readers keep the envelope title and must not recompute from rounded scores.
*/
living_display_intervals?: readonly CandidateInterval[] | null;
segment_minutes?: readonly SegmentMinute[];
segment_targets?: readonly TargetChart[];
segment_scan_complete?: boolean;
@@ -472,6 +472,10 @@ export type RangeNarrationInput = {
fitPercent?: number | null;
/** Scored tap answers (choice, not unsure). The delivery body counts these, not typed events (BUG-1085). */
choiceCount?: number | null;
/** Living-cluster label. When set, it replaces the single credible-range span. */
displayLabel?: string | null;
displayWidthMinutes?: number | null;
displaySegmentCount?: number | null;
};
function clockMinutes(value: string): number | null {
@@ -540,10 +544,11 @@ function progressClause(
openingRange: readonly [string, string] | null | undefined,
rangeText: string,
currentRange: readonly [string, string],
currentWidthOverride?: number | null,
): string | null {
if (!openingRange) return null;
const openingWidth = rangeWidthMinutes(openingRange[0], openingRange[1]);
const currentWidth = rangeWidthMinutes(currentRange[0], currentRange[1]);
const currentWidth = currentWidthOverride ?? rangeWidthMinutes(currentRange[0], currentRange[1]);
if (!openingWidth || !currentWidth || currentWidth >= openingWidth) return null;
return `已经从最初的 ${openingWidth} 分钟收到 ${rangeText} 这 ${currentWidth} 分钟`;
}
@@ -554,10 +559,10 @@ export function nonConvergingRangeNarration(
): string {
const range = input.credibleRange ?? null;
const representative = input.representativeTime?.trim() || null;
const rangeText = formatClockRange(range);
const rangeText = input.displayLabel?.trim() || formatClockRange(range);
const variant = input.variant ?? "delivery";
const progress = range && rangeText
? progressClause(input.openingRange, rangeText, range)
const progress = rangeText && (range || input.displayWidthMinutes)
? progressClause(input.openingRange, rangeText, range ?? ["", ""], input.displayWidthMinutes)
: null;
const reason = variant === "delivery" ? stopReasonPrefix(input.stopReason) : null;
const explain = variant === "delivery" ? (input.stopExplain?.trim() || null) : null;
@@ -615,11 +620,16 @@ export function nonConvergingRangeNarration(
* invites more events.
*/
export function deliveryTurnNarration(input: RangeNarrationInput = {}): string {
const rangeText = formatClockRange(input.credibleRange ?? null);
const rangeText = input.displayLabel?.trim() || formatClockRange(input.credibleRange ?? null);
const segmentNote = input.displaySegmentCount && input.displaySegmentCount >= 2
&& typeof input.displayWidthMinutes === "number"
&& !(input.displayLabel ?? "").includes("共约")
? `剩余约 ${input.displayWidthMinutes} 分钟,分 ${input.displaySegmentCount} 段。`
: "";
const sentence1 = input.segmentSummary
? chartSummaryCopy(input.segmentSummary) + (rangeText ? `分钟范围 ${rangeText}。` : "")
: input.productDelivery ? unknownProductDeliveryCopy(input.productDelivery) + (rangeText ? `分钟范围 ${rangeText}。` : "")
: rangeText ? `目前范围 ${rangeText}。` : "当前几个候选还分不开。";
? chartSummaryCopy(input.segmentSummary) + (rangeText ? `分钟范围 ${rangeText}。` : "") + segmentNote
: input.productDelivery ? unknownProductDeliveryCopy(input.productDelivery) + (rangeText ? `分钟范围 ${rangeText}。` : "") + segmentNote
: rangeText ? `目前范围 ${rangeText}。${segmentNote}` : "当前几个候选还分不开。";
const choices = typeof input.choiceCount === "number" && Number.isFinite(input.choiceCount)
? Math.max(0, Math.trunc(input.choiceCount))
: 0;
@@ -24,6 +24,7 @@ import {
import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service";
import { CHOICE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action";
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
import { livingDisplayNarrationFromStored } from "@/lib/rectification-agentic/core/candidate-window.ts";
import { parseAgentChoiceCopy } from "@/lib/rectification-agentic/v9/choice-card";
import {
optionIdForAnswerClass,
@@ -262,15 +263,20 @@ export async function handleRectificationTypedMessage(
narrateAdopt,
});
const allowDelivery = deliveryNarrationAllowed(decision, decision.nextAction);
const living = livingDisplayNarrationFromStored(
previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt)?.living_display_intervals,
birthDate,
) ?? {};
const intermediate = nonConvergingRangeNarration({
productDelivery: decision.productDelivery,
segmentSummary: decision.segmentSummary,
credibleRange: decision.credibleRange,
representativeTime: decision.representativeTime,
variant: "intermediate",
...living,
});
const assistantMessage = allowDelivery
? (idle.hostNarration || nonConvergingRangeNarration(decision))
? (idle.hostNarration || nonConvergingRangeNarration({ ...decision, ...living }))
: ((idle.terminalNote ? "" : idle.hostNarration) || intermediate);
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
@@ -360,7 +366,11 @@ export async function handleRectificationTypedMessage(
caseId,
narrateAdopt,
});
const assistantMessage = idle.hostNarration || nonConvergingRangeNarration(decision);
const living = livingDisplayNarrationFromStored(
previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt)?.living_display_intervals,
birthDate,
) ?? {};
const assistantMessage = idle.hostNarration || nonConvergingRangeNarration({ ...decision, ...living });
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId,
userMessage: parsed.data.message ?? null,
@@ -31,6 +31,7 @@ import { attemptTimeoutForRemainingBudget, canStartRetryAttempt } from "../../re
import { stripQuestionSentences, stripVerbalWindowChange } from "./collect-prompt";
import { focusSpokenPrompt } from "./turn-question";
import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
import { livingDisplayFromStoredIntervals } from "../core/candidate-window.ts";
import {
activityChangedFromTool,
mapStreamChunkToActivity,
@@ -647,11 +648,15 @@ export async function streamV9Attempt(
// P3: the only clocks and percentages a model sentence may carry are this
// turn's server facts (range, representative minute, event fit rate).
const fitRate = refinementFromDecisionReceipt(latestDossier.latestResult?.decisionReceipt ?? null).event_fit_rate;
const whitelistSources = [
{ credibleRange: rangeAfterEvidence, credibleIntervals: latestInference?.credible_intervals ?? null,
representativeTime: latestInference?.representative_time ?? latestDossier.latestResult?.representativeTime ?? null },
{ credibleRange: decision.credibleRange ?? null, representativeTime: decision.representativeTime ?? null },
].map((source) => spokenFactWhitelist({ ...source, fitPercent: fitRate?.percent ?? null }));
const livingSpeech = livingDisplayFromStoredIntervals(latestInference?.living_display_intervals);
const representativeTime = latestInference?.representative_time ?? latestDossier.latestResult?.representativeTime ?? decision.representativeTime ?? null;
const whitelistSources = (livingSpeech
? [{ credibleIntervals: livingSpeech.intervals, representativeTime }]
: [
{ credibleRange: rangeAfterEvidence, credibleIntervals: latestInference?.credible_intervals ?? null,
representativeTime },
{ credibleRange: decision.credibleRange ?? null, representativeTime: decision.representativeTime ?? null },
]).map((source) => spokenFactWhitelist({ ...source, fitPercent: fitRate?.percent ?? null }));
spokenFacts = {
ranges: [...new Set(whitelistSources.flatMap((item) => item.ranges))],
clocks: [...new Set(whitelistSources.flatMap((item) => item.clocks))],
@@ -11,6 +11,7 @@ import { postAdoptCheckVerdict } from "./post-adopt-verdict.ts";
import { posteriorMap, scoreDeltas } from "../core/decision-fingerprint";
import { productDeliveryForDomain } from "./product-delivery.ts";
import { nextProbe } from "../core/build-state";
import { livingDisplayNarrationFromStored } from "../core/candidate-window.ts";
import {
deliveryNarrationAllowed,
engineCapabilityCeilingFromReceipt,
@@ -352,6 +353,10 @@ function adoptHostNarration(input: {
}): string {
const inference = previousInferenceFromReceipt(input.receipt);
const fit = refinementFromDecisionReceipt(input.receipt).event_fit_rate;
const birthDate = input.receipt?.candidate_window_contract === "dated-v1"
&& typeof input.receipt.declared_birth_date === "string"
? input.receipt.declared_birth_date
: null;
const delivered = deliveryAdoptNarration({
credibleRange: input.decision.credibleRange,
representativeTime: input.decision.representativeTime,
@@ -361,6 +366,7 @@ function adoptHostNarration(input: {
segmentSummary: input.dossier.case.rectificationDomain === null ? null : inference?.segment_summary ?? null,
productDelivery: productDeliveryForDomain(input.dossier.case.rectificationDomain),
openingRange: openingRangeFromDossier(input.dossier),
...(livingDisplayNarrationFromStored(inference?.living_display_intervals, birthDate) ?? {}),
});
// BUG-1147: the segment card already says which chart is credible and which cannot be told
// apart; the minute-candidate hint 「这几个候选按现有信息分不开」 contradicts it.
@@ -2078,6 +2084,7 @@ async function persistExhaustionCollect(input: {
representativeTime: decision.representativeTime ?? input.decision.representativeTime,
openingRange: openingRangeFromDossier(dossier),
variant: "intermediate",
...(livingDisplayNarrationFromStored(inference?.living_display_intervals, birthDate) ?? {}),
});
const persistedFocus = await persistFocusAfterChoice({
accounting: input.accounting,
@@ -2256,6 +2263,7 @@ async function persistExhaustionCollect(input: {
representativeTime: decision.representativeTime ?? input.decision.representativeTime,
openingRange: openingRangeFromDossier(dossier),
variant: "intermediate",
...(livingDisplayNarrationFromStored(inference?.living_display_intervals, birthDate) ?? {}),
});
const hostNarration = exhaustionCarrierNarration(range, hint);
return {
@@ -17,6 +17,7 @@ import type { RectificationDecision } from "../core/rectification-decision.ts";
import { deliveryWidthMinutes } from "../core/precision-gate.ts";
import { rangeWidthMinutes } from "../user-copy.ts";
import { previousInferenceFromReceipt } from "./inference-adapter.ts";
import { livingDisplayFromStoredIntervals } from "../core/candidate-window.ts";
import { decisionBudgetFromInference } from "./decision-from-dossier.ts";
export const RECTIFICATION_TELEMETRY_FIELDS = [
@@ -277,7 +278,10 @@ export function buildRectificationTelemetryRow(input: RectificationTelemetryInpu
questions_open: kindCount("open"),
};
const typedSum = Object.values(typed).reduce((sum, value) => sum + value, 0);
const width = deliveryWidthMinutes(input.rangeDelivery?.range ?? input.decision.credibleRange);
const livingWidth = livingDisplayFromStoredIntervals(
previousInferenceFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null)?.living_display_intervals,
)?.widthMinutes ?? null;
const width = livingWidth ?? deliveryWidthMinutes(input.rangeDelivery?.range ?? input.decision.credibleRange);
return {
window_radius_minutes: windowRadiusMinutes(input.dossier.case.candidateRange),
birth_time_source: telemetryBirthTimeSource(input.dossier.case),
@@ -492,6 +492,7 @@ export function contrastProbesFromReceipt(receipt: Readonly<Record<string, unkno
/** Timeline range line. Never says 收窄. Ban the exhausted-deadend phrase. */
export function rectificationReadonlyRangeCopy(input: Readonly<{
range: readonly [string, string];
displayLabel?: string | null;
sessionOutcome?: string | null;
discriminatingEventProbes?: unknown;
contrastProbes?: unknown;
@@ -509,7 +510,8 @@ export function rectificationReadonlyRangeCopy(input: Readonly<{
: outcome === "collect_evidence"
? "再说一件带年月的事就能继续"
: "还在核对";
return `目前范围 ${input.range[0]}–${input.range[1]},${suffix}`;
const shown = input.displayLabel?.trim() || `${input.range[0]}–${input.range[1]}`;
return `目前范围 ${shown},${suffix}`;
}
/** Adoption live-row copy: the minute is shown so the reader knows what is being applied. */
@@ -0,0 +1,542 @@
{
"fixtureProvenance": {
"command": "python scripts/research/varga_resolution_persisted_replay.py --study --modes all_correct,flip_1,flip_3,flip_5 --orderings off --radii 10,30,60 --limit 0 --code-sha 712a0fd2a19947405a1dfb486bad96a7661d41c3",
"code_sha": "712a0fd2a19947405a1dfb486bad96a7661d41c3",
"radius_minutes": 60,
"anchor_date": "2000-01-01",
"answered_probe_count": 8,
"note": "One finished ±60 persisted replay. Only scores, status, and cluster intervals. Birth date, place, name, and case id are omitted. Interval dates are re-anchored on 2000-01-01."
},
"candidates": [
{
"id": "c0",
"posterior_score": 6,
"probability": 0,
"status": "eliminated",
"time": "10:37",
"window_index": 2,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "10:36",
"end": "10:41"
}
]
},
{
"id": "c1",
"posterior_score": 2,
"probability": 0,
"status": "eliminated",
"time": "10:47",
"window_index": 12,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "10:46",
"end": "10:47"
}
]
},
{
"id": "c2",
"posterior_score": 6,
"probability": 0,
"status": "eliminated",
"time": "10:48",
"window_index": 13,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "10:48",
"end": "10:51"
}
]
},
{
"id": "c3",
"posterior_score": 6,
"probability": 0,
"status": "eliminated",
"time": "10:35",
"window_index": 0,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "10:35",
"end": "10:35"
}
]
},
{
"id": "c4",
"posterior_score": 10,
"probability": 0.03715415577111693,
"status": "active",
"time": "10:59",
"window_index": 24,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "10:54",
"end": "10:59"
}
]
},
{
"id": "c5",
"posterior_score": 10,
"probability": 0.03715415577111693,
"status": "active",
"time": "10:52",
"window_index": 17,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "10:52",
"end": "10:53"
}
]
},
{
"id": "c6",
"posterior_score": 10,
"probability": 0.03715415577111693,
"status": "active",
"time": "10:45",
"window_index": 10,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "10:42",
"end": "10:45"
}
]
},
{
"id": "c7",
"posterior_score": 10,
"probability": 0.03715415577111693,
"status": "active",
"time": "11:04",
"window_index": 29,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:00",
"end": "11:06"
}
]
},
{
"id": "c8",
"posterior_score": 14,
"probability": 0.10099546648436392,
"status": "active",
"time": "11:28",
"window_index": 53,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:25",
"end": "11:29"
}
]
},
{
"id": "c9",
"posterior_score": 2,
"probability": 0,
"status": "eliminated",
"time": "12:07",
"window_index": 92,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "12:07",
"end": "12:12"
}
]
},
{
"id": "c10",
"posterior_score": 17,
"probability": 0.21380740422520322,
"status": "equivalent",
"time": "11:30",
"window_index": 55,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:30",
"end": "11:30"
}
]
},
{
"id": "c11",
"posterior_score": 9,
"probability": 0.028935685608902823,
"status": "active",
"time": "11:19",
"window_index": 44,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:19",
"end": "11:24"
}
]
},
{
"id": "c12",
"posterior_score": 3,
"probability": 0,
"status": "eliminated",
"time": "11:13",
"window_index": 38,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:13",
"end": "11:14"
}
]
},
{
"id": "c13",
"posterior_score": 9,
"probability": 0.028935685608902823,
"status": "active",
"time": "11:15",
"window_index": 40,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:15",
"end": "11:18"
}
]
},
{
"id": "c14",
"posterior_score": -1,
"probability": 0,
"status": "eliminated",
"time": "11:48",
"window_index": 73,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:44",
"end": "11:48"
}
]
},
{
"id": "c15",
"posterior_score": 1,
"probability": 0,
"status": "eliminated",
"time": "12:05",
"window_index": 90,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "12:01",
"end": "12:06"
}
]
},
{
"id": "c16",
"posterior_score": -3,
"probability": 0,
"status": "eliminated",
"time": "12:00",
"window_index": 85,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:59",
"end": "12:00"
}
]
},
{
"id": "c17",
"posterior_score": 3,
"probability": 0,
"status": "eliminated",
"time": "11:12",
"window_index": 37,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:09",
"end": "11:12"
}
]
},
{
"id": "c18",
"posterior_score": 17,
"probability": 0.21380740422520322,
"status": "equivalent",
"time": "11:32",
"window_index": 57,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:31",
"end": "11:36"
}
]
},
{
"id": "c19",
"posterior_score": 13,
"probability": 0.07865534838468445,
"status": "equivalent",
"time": "11:43",
"window_index": 68,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:43",
"end": "11:43"
}
]
},
{
"id": "c20",
"posterior_score": 13,
"probability": 0.07865534838468445,
"status": "equivalent",
"time": "11:42",
"window_index": 67,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:41",
"end": "11:42"
}
]
},
{
"id": "c21",
"posterior_score": 13,
"probability": 0.07865534838468445,
"status": "active",
"time": "11:37",
"window_index": 62,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:37",
"end": "11:40"
}
]
},
{
"id": "c22",
"posterior_score": 9,
"probability": 0.028935685608902823,
"status": "active",
"time": "11:07",
"window_index": 32,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:07",
"end": "11:08"
}
]
},
{
"id": "c23",
"posterior_score": -3,
"probability": 0,
"status": "eliminated",
"time": "11:49",
"window_index": 74,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:49",
"end": "11:54"
}
]
},
{
"id": "c24",
"posterior_score": -3,
"probability": 0,
"status": "eliminated",
"time": "11:58",
"window_index": 83,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:57",
"end": "11:58"
}
]
},
{
"id": "c25",
"posterior_score": -3,
"probability": 0,
"status": "eliminated",
"time": "11:56",
"window_index": 81,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "11:55",
"end": "11:56"
}
]
},
{
"id": "c26",
"posterior_score": -4,
"probability": 0,
"status": "eliminated",
"time": "12:26",
"window_index": 111,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "12:26",
"end": "12:27"
}
]
},
{
"id": "c27",
"posterior_score": -4,
"probability": 0,
"status": "eliminated",
"time": "12:16",
"window_index": 101,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "12:13",
"end": "12:19"
}
]
},
{
"id": "c28",
"posterior_score": -4,
"probability": 0,
"status": "eliminated",
"time": "12:28",
"window_index": 113,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "12:28",
"end": "12:29"
}
]
},
{
"id": "c29",
"posterior_score": -4,
"probability": 0,
"status": "eliminated",
"time": "12:20",
"window_index": 105,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "12:20",
"end": "12:25"
}
]
},
{
"id": "c30",
"posterior_score": -4,
"probability": 0,
"status": "eliminated",
"time": "12:33",
"window_index": 118,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "12:32",
"end": "12:35"
}
]
},
{
"id": "c31",
"posterior_score": -4,
"probability": 0,
"status": "eliminated",
"time": "12:31",
"window_index": 116,
"intervals": [
{
"day_offset": 0,
"end_day_offset": 0,
"start": "12:30",
"end": "12:31"
}
]
}
],
"expected": {
"member_ids": [
"c10",
"c18",
"c8",
"c21",
"c20",
"c19",
"c6",
"c5",
"c4",
"c7"
],
"segment_count": 3,
"width_minutes": 38,
"label": "10:42–10:45、10:52–11:06、11:25–11:43",
"outside_lead_count": 3
}
}
@@ -0,0 +1,377 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { RectificationRangeDelivery } from "../src/components/rectification-range-delivery.tsx";
import {
inclusiveIntervalMinutes,
livingDisplayFromCandidates,
livingDisplayFromReceipt,
mergeLivingDisplayIntervals,
unionWindowIntervals,
windowIntervalsLabel,
type WindowInterval,
} from "../src/lib/rectification-agentic/core/candidate-window.ts";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { livingDisplayForInference, stillValidCandidates } from "../src/lib/rectification-agentic/core/credible-range.ts";
import type { InferenceCandidate } from "../src/lib/rectification-agentic/core/types.ts";
import { deliveryTurnNarration } from "../src/lib/rectification-agentic/user-copy.ts";
import type { RectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
const DAY = "2000-01-01";
const ID_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
const ID_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2";
const ID_C = "cccccccc-cccc-4ccc-8ccc-ccccccccccc3";
function at(clock: string): string {
return `${DAY}T${clock}`;
}
function cluster(start: string, end: string): WindowInterval {
const startOffset = Number(start.slice(0, 2)) * 60 + Number(start.slice(3, 5));
const endOffset = Number(end.slice(0, 2)) * 60 + Number(end.slice(3, 5));
return {
segment_index: 0,
start_index: startOffset,
end_index: endOffset,
start_offset_minutes: startOffset,
end_offset_minutes: endOffset,
start_at: at(start),
end_at: at(end),
};
}
test("one living segment stays one segment", () => {
const merged = mergeLivingDisplayIntervals([cluster("08:00", "08:09")]);
assert.equal(merged.length, 1);
assert.equal(inclusiveIntervalMinutes(merged), 10);
assert.equal(windowIntervalsLabel(merged, DAY), "08:00–08:09");
});
test("a four-minute gap merges and a five-minute gap stays two segments", () => {
const merged = mergeLivingDisplayIntervals([cluster("08:00", "08:10"), cluster("08:15", "08:20")]);
assert.equal(merged.length, 1);
assert.equal(merged[0]?.end_at, at("08:20"));
assert.equal(inclusiveIntervalMinutes(merged), 21);
const split = mergeLivingDisplayIntervals([cluster("08:00", "08:10"), cluster("08:16", "08:20")]);
assert.equal(split.length, 2);
assert.equal(inclusiveIntervalMinutes(split), 16);
assert.equal(windowIntervalsLabel(split, DAY), "08:00–08:10、08:16–08:20");
});
test("five segments show the first three and the total remaining minutes", () => {
const rows = [
cluster("08:00", "08:01"),
cluster("08:12", "08:13"),
cluster("08:24", "08:25"),
cluster("08:36", "08:37"),
cluster("08:48", "08:49"),
];
const merged = mergeLivingDisplayIntervals(rows);
assert.equal(merged.length, 5);
assert.equal(inclusiveIntervalMinutes(merged), 10);
assert.equal(
windowIntervalsLabel(merged, DAY),
"08:00–08:01、08:12–08:13、08:24–08:25,等 5 段,共约 10 分钟",
);
});
test("the declared-segment envelope still fills a gap the display keeps", () => {
const rows = [cluster("08:00", "08:01"), cluster("08:48", "08:49")];
const envelope = unionWindowIntervals(rows);
assert.equal(envelope.length, 1);
assert.equal(envelope[0]?.start_at, at("08:00"));
assert.equal(envelope[0]?.end_at, at("08:49"));
assert.equal(mergeLivingDisplayIntervals(rows).length, 2);
});
function storedIntervals(rows: readonly WindowInterval[]) {
return rows.map((row) => ({ start_at: row.start_at, end_at: row.end_at }));
}
function cardResult(livingIntervals?: readonly WindowInterval[] | null): RectificationCandidateResult {
const early = cluster("08:00", "08:09");
const late = cluster("08:40", "08:49");
const dropped = cluster("09:20", "09:29");
return {
resultId: "result-living",
candidates: [
{ candidateId: ID_A, rank: 1, time: "08:04", relativeSupport: 20, tiedMinuteCount: 1, clusterIntervals: [early] },
{ candidateId: ID_B, rank: 2, time: "08:44", relativeSupport: 14, tiedMinuteCount: 1, clusterIntervals: [late] },
{ candidateId: ID_C, rank: 3, time: "09:24", relativeSupport: 10, tiedMinuteCount: 1, clusterIntervals: [dropped] },
],
overallConfidence: "low",
selectionAllowed: false,
canAdopt: false,
confirmationAllowed: false,
decisionReceipt: livingIntervals === undefined ? null : {
inference_state: { living_display_intervals: livingIntervals ? storedIntervals(livingIntervals) : null },
},
representativeTime: "08:04",
selectedTime: null,
selectionKind: null,
houseTable: null,
houseTablesByTime: {},
natalRecast: null,
techniqueAudit: [],
windowTransitions: [],
eventDashaLedger: [],
dashaAgreement: null,
lagnaContrast: null,
nakshatraBoundary: null,
precisionStage: null,
oosBlindPrompts: [],
confirmationGate: { confirmation_allowed: false } as RectificationCandidateResult["confirmationGate"],
validated: false,
completionStatus: null,
sessionOutcome: "provisional_range",
declaredBirthDate: DAY,
credibleRange: ["08:00", "08:49"],
credibleIntervals: [cluster("08:00", "08:49")],
rangeDelivery: null,
verificationReportMarkdown: null,
};
}
test("the delivery card title uses stored living segments, not the segment envelope", () => {
const early = cluster("08:00", "08:09");
const late = cluster("08:40", "08:49");
const result = cardResult([early, late]);
assert.equal(livingDisplayFromCandidates(result.candidates, DAY), null);
const living = livingDisplayFromReceipt(result.decisionReceipt, DAY);
assert.ok(living);
assert.equal(living.segmentCount, 2);
assert.equal(living.widthMinutes, 20);
assert.equal(living.label, "08:00–08:09、08:40–08:49");
const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result,
acceptingCandidateId: null,
readonly: true,
onAccept: () => undefined,
}));
assert.match(html, /目前范围 08:00–08:09、08:40–08:49/);
assert.doesNotMatch(html, /08:00–08:49/);
const narration = deliveryTurnNarration({
credibleRange: result.credibleRange,
representativeTime: "08:04",
displayLabel: living.label,
displayWidthMinutes: living.widthMinutes,
displaySegmentCount: living.segmentCount,
});
assert.match(narration, /目前范围 08:00–08:09、08:40–08:49/);
assert.match(narration, /剩余约 20 分钟,分 2 段/);
assert.doesNotMatch(narration, /定到分钟/);
});
test("a card without stored segments keeps the envelope title", () => {
const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result: cardResult(),
acceptingCandidateId: null,
readonly: true,
onAccept: () => undefined,
}));
assert.match(html, /目前范围 08:00–08:49/);
assert.doesNotMatch(html, /08:00–08:09、08:40–08:49/);
});
test("the delivery card reads stored intervals, not rounded candidate scores", () => {
const source = readFileSync(new URL("../src/components/rectification-range-delivery.tsx", import.meta.url), "utf8");
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
// 原值: livingDisplayFromCandidates(result.candidates
// 新值: livingDisplayFromReceipt(result.decisionReceipt
// 原因: 解析后的候选只有四舍五入的 relativeSupport。峰值 7.6、候选 0.4 取整后差 8 分,会被排除,真值可能落在被藏起来的那段。分段在写盘时按原始 posterior_score 算好,卡片和只读行只读存下来的区间。
assert.match(source, /livingDisplayFromReceipt\(result\.decisionReceipt/);
assert.doesNotMatch(source, /livingDisplayFromCandidates\(result\.candidates/);
assert.match(source, /windowIntervalsLabel\(result\.credibleIntervals/);
assert.match(chat, /livingDisplayFromReceipt\(candidateResult\.decisionReceipt/);
assert.doesNotMatch(chat, /livingDisplayFromCandidates\(candidateResult\.candidates/);
});
function inferenceRow(
id: string,
score: number,
intervals: readonly WindowInterval[] | null,
probability = score,
): InferenceCandidate {
return {
id,
time: "08:00",
cluster_range: ["08:00", "08:09"],
prior_score: score,
posterior_score: score,
probability,
status: "active",
rank: 1,
strong_conflict_count: 0,
...(intervals ? { cluster_intervals: intervals } : {}),
};
}
test("a 7.2 point gap stays in the display after rounding would look like 8", () => {
const peak = inferenceRow("peak", 7.6, [cluster("08:00", "08:09")], 7.6);
const edge = inferenceRow("edge", 0.4, [cluster("08:40", "08:49")], 0.4);
assert.ok(Math.round(7.6) - Math.round(0.4) >= 8);
assert.ok(7.6 - 0.4 < 8);
const display = livingDisplayForInference([peak, edge], DAY);
assert.deepEqual(display?.memberIds, stillValidCandidates([peak, edge]).map((row) => row.id));
assert.equal(display?.label, "08:00–08:09、08:40–08:49");
});
test("one living candidate without intervals drops the segmented title", () => {
const peak = inferenceRow("peak", 10, [cluster("08:00", "08:09")], 10);
const living = inferenceRow("living", 9, null, 9);
assert.equal(livingDisplayForInference([peak, living], DAY), null);
assert.deepEqual(stillValidCandidates([peak, living]).map((row) => row.id), ["peak", "living"]);
});
test("a candidate outside the lead can lack intervals without hiding the living segment", () => {
const peak = inferenceRow("peak", 10, [cluster("08:00", "08:09")], 10);
const outside = inferenceRow("outside", 1, null, 1);
const display = livingDisplayForInference([peak, outside], DAY);
assert.deepEqual(display?.memberIds, ["peak"]);
assert.equal(display?.label, "08:00–08:09");
});
test("display members match stillValidCandidates, including when probability and score disagree", () => {
const highProbability = inferenceRow("likely", 10, [cluster("08:00", "08:09")], 0.9);
const highScore = inferenceRow("loud", 100, [cluster("08:40", "08:49")], 0.1);
const rows = [highScore, highProbability];
const display = livingDisplayForInference(rows, DAY);
assert.deepEqual(display?.memberIds, stillValidCandidates(rows).map((row) => row.id));
assert.deepEqual(display?.memberIds, ["likely", "loud"]);
assert.equal(display?.segmentCount, 2);
});
test("buildInferenceState stores both sides of a 7.2 point gap", () => {
const early = cluster("08:00", "08:09");
const late = cluster("08:40", "08:49");
const state = buildInferenceState({
range_start: "08:00",
range_end: "08:49",
candidates: [
{
id: "peak",
time: "08:04",
relative_support: 7.6,
candidate_date: DAY,
window_index: 484,
window_offset_minutes: 484,
segment_index: 0,
cluster_intervals: [early],
},
{
id: "edge",
time: "08:44",
relative_support: 0.4,
candidate_date: DAY,
window_index: 524,
window_offset_minutes: 524,
segment_index: 0,
cluster_intervals: [late],
},
],
events: [],
probes: [],
});
assert.deepEqual(
state.living_display_intervals,
storedIntervals([early, late]),
);
assert.deepEqual(
stillValidCandidates(state.candidates).map((row) => row.id).sort(),
["edge", "peak"],
);
});
const REPLAY_ANCHOR = "2000-01-01";
function replayStamp(offset: number, clock: string): string {
const date = new Date(`${REPLAY_ANCHOR}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + offset);
return `${date.toISOString().slice(0, 10)}T${clock}`;
}
function replayWindow(startAt: string, endAt: string): WindowInterval {
return {
segment_index: 0,
start_index: 0,
end_index: 0,
start_offset_minutes: 0,
end_offset_minutes: 0,
start_at: startAt,
end_at: endAt,
};
}
test("a real ±60 replay sample keeps living members and a gapped title", () => {
const fixture = JSON.parse(readFileSync(
new URL("./fixtures/rectification-living-display-radius60-20261017.json", import.meta.url),
"utf8",
)) as {
fixtureProvenance: { code_sha: string; radius_minutes: number; command: string; answered_probe_count: number };
candidates: readonly {
id: string;
posterior_score: number;
probability: number;
status: InferenceCandidate["status"];
time: string;
window_index: number | null;
intervals: readonly { day_offset: number; end_day_offset: number; start: string; end: string }[];
}[];
expected: { member_ids: readonly string[]; segment_count: number; width_minutes: number; label: string; outside_lead_count: number };
};
assert.equal(fixture.fixtureProvenance.code_sha, "712a0fd2a19947405a1dfb486bad96a7661d41c3");
assert.equal(fixture.fixtureProvenance.radius_minutes, 60);
assert.match(fixture.fixtureProvenance.command, /--radii 10,30,60/);
assert.ok(fixture.fixtureProvenance.answered_probe_count >= 1);
assert.ok(fixture.expected.outside_lead_count >= 1);
const rows: InferenceCandidate[] = fixture.candidates.map((row) => ({
id: row.id,
time: row.time,
cluster_range: [row.time, row.time],
prior_score: row.posterior_score,
posterior_score: row.posterior_score,
probability: row.probability,
status: row.status,
rank: 1,
strong_conflict_count: 0,
...(row.window_index === null ? {} : { window_index: row.window_index }),
...(row.intervals.length ? {
cluster_intervals: row.intervals.map((part) => replayWindow(
replayStamp(part.day_offset, part.start),
replayStamp(part.end_day_offset, part.end),
)),
} : {}),
}));
const display = livingDisplayForInference(rows, REPLAY_ANCHOR);
assert.ok(display);
assert.deepEqual(display.memberIds, stillValidCandidates(rows).map((row) => row.id));
assert.deepEqual(display.memberIds, fixture.expected.member_ids);
assert.equal(display.segmentCount, fixture.expected.segment_count);
assert.equal(display.widthMinutes, fixture.expected.width_minutes);
assert.equal(display.label, fixture.expected.label);
assert.ok(display.segmentCount >= 2);
const gaps = display.intervals.slice(1).map((row, index) => (
(Date.parse(`${row.start_at}:00Z`) - Date.parse(`${display.intervals[index]!.end_at}:00Z`)) / 60000 - 1
));
assert.ok(gaps.some((gap) => gap > 4));
const result = cardResult(display.intervals.map((row) => replayWindow(row.start_at, row.end_at)));
const html = renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result: {
...result,
declaredBirthDate: REPLAY_ANCHOR,
credibleRange: ["10:42", "11:43"],
credibleIntervals: [replayWindow(`${REPLAY_ANCHOR}T10:42`, `${REPLAY_ANCHOR}T11:43`)],
},
acceptingCandidateId: null,
readonly: true,
onAccept: () => undefined,
}));
assert.match(html, /目前范围 10:42–10:45、10:52–11:06、11:25–11:43/);
assert.doesNotMatch(html, /10:42–11:43/);
assert.equal(display.widthMinutes, 38);
assert.ok(display.widthMinutes < 62);
});
@@ -1,10 +1,12 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { closeLocalPostgresDataPool, createLocalPostgresDataClient } from "../../src/lib/db/local-postgres-client-core.ts";
import { resolveActiveSkillPackage } from "../../src/lib/skill-package-registry.ts";
import { stillValidCandidates } from "../../src/lib/rectification-agentic/core/credible-range.ts";
import { publicNextAction } from "../../src/lib/rectification-agentic/core/rectification-decision.ts";
import { DEFAULT_MAX_DISCRIMINATION_ROUNDS, type AnswerClass, type InferenceState } from "../../src/lib/rectification-agentic/core/types.ts";
import { rawMinuteWeights, segmentsForMinutes, summarizeSegmentWeights, targetChartsForDomain } from "../../src/lib/rectification-agentic/core/segment-summary.ts";
import { applyRectificationChoice, inferenceForPersistedAnswer, persistNextInterviewAfterChoice } from "../../src/lib/rectification-agentic/v9/answer-choice.ts";
import { CHOICE_ACTION, outcomeIdForOption } from "../../src/lib/rectification-agentic/v9/choice-action.ts";
@@ -25,9 +27,67 @@ type ReplayRow = {
range: { start_time: string; end_time: string };
events: { event_kind: EvidenceKind; domain: string; date_start: string; date_end: string; precision: string; summary: string }[];
};
type AnswerMode = "all_correct" | "flip_1" | "flip_3" | "flip_5";
type StudyInput = {
modes?: AnswerMode[];
orderings?: Array<"off" | "on">;
code_sha?: string;
};
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
const input = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { rows: ReplayRow[] };
const input = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { rows: ReplayRow[]; study?: StudyInput };
const study = input.study ?? null;
const orderingFlags = study ? (study.orderings ?? ["off"]).map((item) => item === "on") : [false, true];
const modes: AnswerMode[] = study?.modes?.length ? study.modes : ["all_correct"];
const maxRounds = study ? DEFAULT_MAX_DISCRIMINATION_ROUNDS : 6;
const passes = orderingFlags.flatMap((enabled) => modes.map((mode) => ({ enabled, mode })));
function caseLabel(key: string): string {
return createHash("sha256").update(key).digest("hex").slice(0, 16);
}
function shiftDate(day: string, delta: number): string {
const date = new Date(`${day}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + delta);
return date.toISOString().slice(0, 10);
}
function inclusiveMinutes(rows: readonly { start_at: string; end_at: string }[]): number {
return rows.reduce((sum, row) => {
const width = (Date.parse(`${row.end_at}:00Z`) - Date.parse(`${row.start_at}:00Z`)) / 60000 + 1;
return Number.isFinite(width) && width > 0 ? sum + width : sum;
}, 0);
}
function envelopeIntervals(state: InferenceState, birthDate: string): { start_at: string; end_at: string }[] {
if (state.credible_intervals?.length) {
return state.credible_intervals.map((row) => ({ start_at: row.start_at, end_at: row.end_at }));
}
const range = state.credible_range;
if (!range) return [];
if (range[0] <= range[1]) return [{ start_at: `${birthDate}T${range[0]}`, end_at: `${birthDate}T${range[1]}` }];
const previous = shiftDate(birthDate, -1);
return [
{ start_at: `${previous}T${range[0]}`, end_at: `${previous}T23:59` },
{ start_at: `${birthDate}T00:00`, end_at: `${birthDate}T${range[1]}` },
];
}
function containsTruth(rows: readonly { start_at: string; end_at: string }[], birthDate: string, minute: string): boolean {
const stamps = [-1, 0, 1].map((delta) => `${shiftDate(birthDate, delta)}T${minute}`);
return rows.some((row) => stamps.some((stamp) => stamp >= row.start_at && stamp <= row.end_at));
}
function median(values: number[]): number | null {
if (!values.length) return null;
const sorted = [...values].sort((left, right) => left - right);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
}
function flippedClass(value: AnswerClass): AnswerClass {
return value === "no" ? "yes" : "no";
}
function dayOffset(birthDate: string, stamp: string): number {
const birth = Date.parse(`${birthDate}T00:00:00Z`);
const day = Date.parse(`${stamp.slice(0, 10)}T00:00:00Z`);
return Math.round((day - birth) / 86400000);
}
let displayFixture: Record<string, unknown> | null = null;
const records: Record<string, unknown>[] = [];
const previousOrder = process.env.RECTIFICATION_SEGMENT_ORDER;
const quote = (value: unknown) => `'${String(value).replaceAll("'", "''")}'`;
@@ -43,10 +103,16 @@ try {
const service = createLocalPostgresDataClient(url, null, "service_role");
const skill = resolveActiveSkillPackage("jyotish-birth-time-rectification");
for (const row of input.rows) {
for (const enabled of [false, true]) {
for (const pass of passes) {
const enabled = pass.enabled;
const mode = pass.mode;
const flipAt = mode === "flip_1" ? 1 : mode === "flip_3" ? 3 : mode === "flip_5" ? 5 : null;
process.env.RECTIFICATION_SEGMENT_ORDER = enabled ? "on" : "off";
const userId = randomUUID(), caseId = randomUUID(), sessionId = randomUUID(), sourceSessionId = randomUUID();
const trace: Record<string, unknown> = { case_key: row.case_key, radius: row.radius,
const trace: Record<string, unknown> = {
case_key: study ? caseLabel(row.case_key) : row.case_key,
radius: row.radius,
...(study ? { mode } : {}),
ordering: enabled ? "on-experiment" : "off-default", opening: "explicit-fictional-case-seed-not-opening-success",
asked: [], gates: [], stop: null, stage: "seed", metrics: null };
const ledger = () => JSON.parse(fixture.psql(`select json_build_object(
@@ -102,7 +168,9 @@ try {
}
trace.stage = "scoreAndPersistCurrentEvidence";
await scoreAndPersistCurrentEvidence({ accounting: service, userId, caseId });
for (let round = 0; round < 6; round++) {
let scoredOrdinal = 0;
let flipApplied = false;
for (let round = 0; round < maxRounds; round++) {
let dossier = await loadV9CaseDossier(service, userId, caseId);
const state = inferenceForPersistedAnswer(dossier.latestResult?.decisionReceipt);
const decision = decideAfterInferenceChange({ dossier, state, userStopped: false, birthDate: snapshot.birth_date });
@@ -131,10 +199,18 @@ try {
choice_kind: schema.choice_kind ?? null, scoring: schema.scoring !== false });
break;
}
const answerClass = segmentReplayOracleAnswer(probe, row.oracle_truth_minute, ownedState?.transitions);
const oracleClass = segmentReplayOracleAnswer(probe, row.oracle_truth_minute, ownedState?.transitions);
const choice = schema.choice as { options?: { key: ChoiceKey }[] } | undefined;
const scoring = schema.scoring !== false;
if (scoring) scoredOrdinal += 1;
const wantFlip = flipAt !== null && scoring && scoredOrdinal === flipAt;
const answerClass = wantFlip ? flippedClass(oracleClass) : oracleClass;
const option = choice?.options?.find(option => outcomeIdForOption(option.key, schema) === answerClass);
if (!option) { trace.stop = "oracle-no-renderable-class-option"; break; }
if (!option) {
trace.stop = wantFlip ? "flip-option-missing" : "oracle-no-renderable-class-option";
break;
}
if (wantFlip) flipApplied = true;
const before = ledger();
trace.stage = "applyRectificationChoice";
const applied = await applyRectificationChoice(service, { userId, caseId, sessionId, actionId: randomUUID(),
@@ -147,11 +223,54 @@ try {
scoring: schema.scoring !== false, ledger_before: before, ledger_after: ledger(),
revision_before: ownedState!.revision, revision_after: afterState?.revision ?? null });
assert.equal(afterState?.revision, ownedState!.revision + 1);
if (round === 5) trace.stop = "six-question-budget";
if (round === maxRounds - 1) trace.stop = maxRounds === 6 ? "six-question-budget" : "round-cap";
}
const final = await loadV9CaseDossier(service, userId, caseId);
const state = inferenceForPersistedAnswer(final.latestResult?.decisionReceipt);
if (state?.segment_minutes?.length) {
if (study && state) {
const envelope = envelopeIntervals(state, snapshot.birth_date);
const stored = (state.living_display_intervals ?? []).filter((row) => row?.start_at && row?.end_at);
const shown = stored.length ? stored : envelope;
const living = stillValidCandidates(state.candidates);
const livingRows = living.flatMap((row) => row.cluster_intervals ?? []);
const remaining = living.length > 0 && living.every((row) => (row.cluster_intervals?.length ?? 0) > 0)
? inclusiveMinutes(livingRows) : inclusiveMinutes(envelope);
const clocks = (rows: readonly { start_at: string; end_at: string }[]) => rows.map((row) => ({
start: row.start_at.slice(11, 16), end: row.end_at.slice(11, 16),
}));
trace.question_count = (trace.asked as unknown[]).length;
trace.flip_applied = flipApplied;
trace.truth_in_envelope = containsTruth(envelope, snapshot.birth_date, row.oracle_truth_minute);
trace.truth_in_display = containsTruth(shown, snapshot.birth_date, row.oracle_truth_minute);
trace.remaining_minutes = remaining;
trace.display_width_minutes = inclusiveMinutes(shown);
trace.display_source = stored.length ? "stored" : "envelope";
trace.credible_range = state.credible_range;
trace.credible_intervals = clocks(envelope);
trace.living_display_intervals = stored.length ? clocks(stored) : null;
if (!displayFixture && row.radius === 60 && mode === "all_correct" && stored.length >= 2) {
displayFixture = {
radius: 60,
mode: "all_correct",
display_width_minutes: inclusiveMinutes(shown),
segment_count: stored.length,
intervals: stored.map((part) => ({
day_offset: dayOffset(snapshot.birth_date, part.start_at),
start: part.start_at.slice(11, 16),
end: part.end_at.slice(11, 16),
})),
members: living.map((candidate) => ({
posterior_score: candidate.posterior_score,
intervals: (candidate.cluster_intervals ?? []).map((part) => ({
day_offset: dayOffset(snapshot.birth_date, part.start_at),
start: part.start_at.slice(11, 16),
end: part.end_at.slice(11, 16),
})),
})),
};
}
}
if (!study && state?.segment_minutes?.length) {
const offsets = Object.fromEntries(state.segment_minutes.map(minute => [minute.time, minute.offset]));
const truthOffset = offsets[row.oracle_truth_minute];
assert.notEqual(truthOffset, undefined);
@@ -164,7 +283,8 @@ try {
trace.ledger_after = ledger();
} catch (error) {
trace.stop = "blocked";
trace.error = error instanceof Error ? error.message : "unknown_error";
trace.error = study ? (error instanceof Error ? error.name : "unknown_error")
: (error instanceof Error ? error.message : "unknown_error");
trace.ledger_after = ledger();
}
records.push(trace);
@@ -194,8 +314,38 @@ const aggregate = Object.fromEntries([...new Set(input.rows.map(row => row.radiu
completed_six: rows.filter(row => (row.asked as unknown[]).length === 6).length, metrics,
redline_status: measured.length === 77 ? "requires-independent-cell-comparison" : "not-evaluable" }];
})));
const blocked = fatal !== null || records.some(row => row.stop === "blocked" || row.metrics === null);
process.stdout.write(JSON.stringify({ scope: "explicit-seeded persisted replay; off-default vs on-experiment; no opening success or provider claim",
fatal, records, aggregate, blocked, distinct_cases: new Set(input.rows.map(row => row.case_key)).size,
requested_traces: input.rows.length * 2, completed_six: records.filter(row => (row.asked as unknown[]).length === 6).length }) + "\n");
const studySummary = study ? Object.fromEntries(modes.flatMap((mode) => (
[...new Set(input.rows.map((row) => row.radius))].map((radius) => {
const rows = records.filter((row) => row.radius === radius && row.mode === mode && row.stop !== "blocked"
&& typeof row.truth_in_envelope === "boolean");
const envelope = rows.filter((row) => row.truth_in_envelope === true).length;
const display = rows.filter((row) => row.truth_in_display === true).length;
const numbers = (key: string) => rows.map((row) => row[key]).filter((value): value is number => typeof value === "number");
return [`${radius}:${mode}`, {
attempted: rows.length,
blocked: records.filter((row) => row.radius === radius && row.mode === mode && row.stop === "blocked").length,
envelope_contains: envelope,
display_contains: display,
envelope_rate: rows.length ? envelope / rows.length : null,
display_rate: rows.length ? display / rows.length : null,
remaining_minutes_median: median(numbers("remaining_minutes")),
display_width_median: median(numbers("display_width_minutes")),
question_count_median: median(numbers("question_count")),
flip_applied: rows.filter((row) => row.flip_applied === true).length,
}];
})
))) : null;
const blocked = fatal !== null || records.some(row => row.stop === "blocked" || (!study && row.metrics === null));
process.stdout.write(JSON.stringify({
scope: study
? "narrower-delivery replay; containment booleans only; no birth clock, date, place or name"
: "explicit-seeded persisted replay; off-default vs on-experiment; no opening success or provider claim",
fatal: study && fatal ? "runner_failure" : fatal,
records, aggregate, study_summary: studySummary, blocked,
max_rounds: maxRounds,
display_fixture: displayFixture,
code_sha: study?.code_sha ?? null,
distinct_cases: new Set(input.rows.map(row => study ? caseLabel(row.case_key) : row.case_key)).size,
requested_traces: input.rows.length * passes.length,
completed_six: records.filter(row => (row.asked as unknown[]).length === 6).length }) + "\n");
process.exitCode = blocked ? 1 : 0;
+8 -6
View File
@@ -16124,18 +16124,20 @@ def cmd_full_reading(args):
planet_lons,
)
report['modules']['navamsha_dasha'] = _public_secondary_dasha_module(secondary_dashas.get('navamsha'))
report['modules']['niryaana_shoola_dasha'] = _public_secondary_dasha_module(
secondary_dashas.get('niryaana_shoola')
)
except Exception as e:
report['modules']['navamsha_dasha'] = {
'status': 'blocked',
'reason': f'navamsha_dasha_producer_failed:{e}',
'periods': [],
}
# F2b: do not publish the current Niryaana Shoola table. It matches Shoola.
# The synastry validator marks this row not applicable, so the pair task stays open.
report['modules']['niryaana_shoola_dasha'] = {
'execution_status': 'blocked',
'reason': '口径更正中,见 TASK-niryaana-shoola-pvr-20261018',
}
report['modules']['niryaana_shoola_dasha'] = {
'status': 'blocked',
'reason': f'niryaana_shoola_dasha_producer_failed:{e}',
'periods': [],
}
_record_stage_timing(
stage_timings,
'dasha_and_core_varga_stack',
+7
View File
@@ -140,6 +140,13 @@ def render_rashi_dasha_chapter(packet: Mapping) -> str:
section = sections[key]
title = title_en if language == "en" else title_zh
lines.extend([f"### {title}", "", _method_line(section, language), ""])
if key == "niryaana_shoola" and section.get("status") == "listed":
lines.extend([
"Child periods stay off this table: the cited book does not give a separate rule for them."
if language == "en"
else "子运规则未定,暂不列出。",
"",
])
if key == "chara" and section.get("status") == "listed":
lines.extend([
"This section is the K.N. Rao Chara table. It is not the Narayana table."
+177 -9
View File
@@ -38,6 +38,17 @@ except ImportError: # pragma: no cover - package import
_MOVABLE = {0, 3, 6, 9}
_FIXED = {1, 4, 7, 10}
_DUAL = {2, 5, 8, 11}
# Nine grahas only. Outer planets and upagrahas are ignored. Nodes are not weighted.
_AYUR_RASI_STRENGTH_BODIES = (
"Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu",
)
_EXALTED_SIGNS = {
"Sun": {0}, "Moon": {1}, "Mars": {9}, "Mercury": {5},
"Jupiter": {3}, "Venus": {11}, "Saturn": {6},
}
_NIRYAANA_SOURCE_ZH = "PVR《Vedic Astrology: An Integrated Approach》"
_NIRYAANA_SOURCE_EN = "PVR, Vedic Astrology: An Integrated Approach"
_NIRYAANA_PROFILE = "pvr_niryaana_shoola_v1"
def _as_date(value: date | datetime | str) -> date:
@@ -303,6 +314,171 @@ def _drig_children(md_sign: int, start: date, end: date, graha: Mapping, planet_
return _equal_children(order, start, end)
def niryaana_shoola_years(sign_idx: int) -> int:
"""Movable, fixed, and dual signs last 7, 8, and 9 years."""
sign_idx = int(sign_idx) % 12
if sign_idx in _FIXED:
return 8
if sign_idx in _DUAL:
return 9
return 7
def select_niryaana_shoola_start_sign(
*,
ascendant_longitude: float,
planet_longitudes: Mapping[str, float],
) -> dict:
"""Pick the stronger of the second and eighth signs for a longevity dasha.
Compare occupant count, then every planet's Jaimini sign aspect (the sign
lord is not weighted), then whether an occupant is exalted. A three-way
tie stays off the table. Count only the nine grahas.
"""
lagna = sign_index(float(ascendant_longitude))
second, eighth = (lagna + 1) % 12, (lagna + 7) % 12
positions = {
name: float(longitude)
for name, longitude in planet_longitudes.items()
if name in _AYUR_RASI_STRENGTH_BODIES and isinstance(longitude, (int, float))
}
candidates = {}
for sign_idx in (second, eighth):
occupants = sorted(
name for name, longitude in positions.items()
if sign_index(longitude) == sign_idx
)
aspecting = sorted(
name for name, longitude in positions.items()
if sign_index(longitude) != sign_idx and sign_idx in _aspecting_signs(sign_index(longitude))
)
exalted = sorted(
name for name in occupants
if sign_idx in _EXALTED_SIGNS.get(name, set())
)
candidates[sign_idx] = {
"sign_index": sign_idx,
"sign": SIGNS[sign_idx],
"occupants": occupants,
"occupant_count": len(occupants),
"rasi_aspecting_planets": aspecting,
"rasi_aspect_count": len(aspecting),
"exalted_occupants": exalted,
}
second_score = (
candidates[second]["occupant_count"],
candidates[second]["rasi_aspect_count"],
bool(candidates[second]["exalted_occupants"]),
)
eighth_score = (
candidates[eighth]["occupant_count"],
candidates[eighth]["rasi_aspect_count"],
bool(candidates[eighth]["exalted_occupants"]),
)
payload = {
"profile": "pvr_niryaana_shoola_ayur_rasi_strength_v1",
"included_bodies": list(_AYUR_RASI_STRENGTH_BODIES),
"ascendant_sign_index": lagna,
"second_sign_index": second,
"eighth_sign_index": eighth,
"candidates": candidates,
"selection_levels": [
"occupant_count",
"all_planet_rasi_aspect_count",
"exalted_occupant_presence",
],
}
if second_score == eighth_score:
return {
**payload,
"status": "blocked",
"reason": "niryaana_shoola_ayur_rasi_strength_tie_requires_source_rule",
}
selected = second if second_score > eighth_score else eighth
return {
**payload,
"status": "selected",
"selected_sign_idx": selected,
"selected_sign": SIGNS[selected],
}
def niryaana_shoola_applicability(
*,
ascendant_longitude: float,
second_strength: float,
eighth_strength: float,
) -> dict:
"""Start from the stronger given second or eighth strength. Equal strength blocks."""
lagna = sign_index(float(ascendant_longitude))
second, eighth = (lagna + 1) % 12, (lagna + 7) % 12
second_strength, eighth_strength = float(second_strength), float(eighth_strength)
if second_strength == eighth_strength:
return {
"applicable": False,
"execution_status": "blocked",
"ascendant_sign_index": lagna,
"second_sign_index": second,
"eighth_sign_index": eighth,
"second_strength": second_strength,
"eighth_strength": eighth_strength,
"rule_family": "pvr_niryaana_shoola_2nd_8th_modality",
"reason": "The cited Niryaana Shoola rule does not provide a tie-break for equal second/eighth strength.",
}
start = second if second_strength > eighth_strength else eighth
return {
"applicable": True,
"ascendant_sign_index": lagna,
"second_sign_index": second,
"eighth_sign_index": eighth,
"second_strength": second_strength,
"eighth_strength": eighth_strength,
"starting_sign_index": start,
"starting_sign": SIGNS[start],
"direction": "forward" if start % 2 == 0 else "reverse",
"rule_family": "pvr_niryaana_shoola_2nd_8th_modality",
"reason": "Niryaana Shoola starts from the stronger second/eighth sign; odd seeds run forward and even seeds reverse.",
}
def niryaana_shoola_mahadashas(birth: date, start_sign: int) -> list[dict]:
"""Twelve mahadashas only. Odd signs go forward; even signs go backward."""
start_sign = int(start_sign) % 12
order = _forward(start_sign) if start_sign % 2 == 0 else _reverse(start_sign)
periods = []
cursor = birth
for sign_idx in order:
years = niryaana_shoola_years(sign_idx)
end = add_years(cursor, years)
periods.append(_period(sign_idx, years, cursor, end))
cursor = end
return periods
def _niryaana_shoola(birth: date, ascendant_longitude: float, planet_lons: Mapping[str, float]) -> dict:
picked = select_niryaana_shoola_start_sign(
ascendant_longitude=ascendant_longitude,
planet_longitudes=planet_lons,
)
if picked.get("status") != "selected":
return _omitted(
_NIRYAANA_PROFILE,
_NIRYAANA_SOURCE_ZH,
_NIRYAANA_SOURCE_EN,
"本轮不上表:二宫与八宫力量相同,原书没有给出进一步的裁决规则。",
"This report does not include Niryaana Shoola: the second and eighth signs are equal, and the source gives no further tie-break.",
)
start_sign = int(picked["selected_sign_idx"])
return _listed(
_NIRYAANA_PROFILE,
_NIRYAANA_SOURCE_ZH,
_NIRYAANA_SOURCE_EN,
niryaana_shoola_mahadashas(birth, start_sign),
direction="forward" if start_sign % 2 == 0 else "reverse",
strength_profile=picked["profile"],
)
def _nine_year(birth: date, lagna: int, planet_lons: Mapping[str, float], rashi: Mapping, profile: str, source_zh: str, source_en: str) -> dict:
seventh = (lagna + 6) % 12
picked = stronger_sign(lagna, seventh, rashi, planet_lons)
@@ -397,15 +573,7 @@ def calculate_rashi_dashas(birth: date | datetime | str, ascendant_longitude: fl
"Crux of Vedic Astrology, printed page 395",
),
"navamsha": _navamsha(birth_day, float(ascendant_longitude), longitudes),
"niryaana_shoola": _nine_year(
birth_day,
lagna,
longitudes,
rashi,
"goel_nirayana_shoola_v1",
"Predicting through Jaimini Astrology,印页 162",
"Predicting through Jaimini Astrology, printed page 162",
),
"niryaana_shoola": _niryaana_shoola(birth_day, float(ascendant_longitude), longitudes),
"lagna_kendradi": _omitted(
"",
"",
@@ -29,6 +29,10 @@ def main() -> int:
parser.add_argument("--output", required=True)
parser.add_argument("--limit", type=int, default=10)
parser.add_argument("--radii", default="10,30,60")
parser.add_argument("--study", action="store_true")
parser.add_argument("--modes", default="all_correct,flip_1,flip_3,flip_5")
parser.add_argument("--orderings", default="off")
parser.add_argument("--code-sha", default="")
args = parser.parse_args()
target = Path(args.output)
if target.exists():
@@ -58,13 +62,32 @@ def main() -> int:
thread.start()
try:
env = {**os.environ, "JYOTISH_API_BASE": origin}
body: dict = {"rows": rows}
if args.study:
body["study"] = {
"modes": [item for item in args.modes.split(",") if item],
"orderings": [item for item in args.orderings.split(",") if item],
"code_sha": args.code_sha,
}
result = subprocess.run(["node", "--import", "tsx", "tests/research/rectification-segment-persisted-replay.ts"],
cwd=ROOT / "frontend", env=env, input=json.dumps({"rows": rows}),
cwd=ROOT / "frontend", env=env, input=json.dumps(body),
text=True, encoding="utf-8", stdout=subprocess.PIPE, check=False)
payload = json.loads(result.stdout)
payload.update({"python_version": platform.python_version(),
"dataset_sha256": hashlib.sha256(Path(args.dataset).read_bytes()).hexdigest(),
"runner_exit": result.returncode})
if args.study:
for row in payload.get("records") or []:
if isinstance(row, dict):
for key in ("asked", "gates", "ledger_before", "ledger_after", "metrics", "targets", "domain"):
row.pop(key, None)
payload["rerun"] = {
"command": "python scripts/research/varga_resolution_persisted_replay.py --study "
f"--modes {args.modes} --orderings {args.orderings} --radii {args.radii} "
f"--limit {args.limit} --code-sha {args.code_sha or '<HEAD>'} --output <new-file>",
"max_rounds": "DEFAULT_MAX_DISCRIMINATION_ROUNDS",
"truth_clock": "omitted; real-case calibration stores containment booleans only",
}
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("x", encoding="utf-8") as stream:
stream.write(json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n")
+48 -77
View File
@@ -564,32 +564,32 @@
},
"niryaana_shoola": {
"sign_indexes": [
10,
11,
0,
1,
2,
3,
4,
5,
6,
7,
10,
9,
8,
9
7,
6,
5,
4,
3,
2,
1,
0
],
"years": [
9,
8,
7,
9,
8,
7,
9,
8,
7,
9,
9,
9,
9,
9,
9,
9,
9,
9
8,
7
]
},
"lagna_kendradi": {
@@ -647,7 +647,6 @@
"subperiod"
],
"niryaana_shoola": [
"start_sign",
"direction",
"years",
"subperiod"
@@ -1224,7 +1223,6 @@
},
"niryaana_shoola": {
"sign_indexes": [
3,
4,
5,
6,
@@ -1235,21 +1233,22 @@
11,
0,
1,
2
2,
3
],
"years": [
8,
9,
7,
8,
9,
7,
8,
9,
7,
8,
9,
9,
9,
9,
9,
9,
9,
9,
9
7
]
},
"lagna_kendradi": {
@@ -1308,7 +1307,6 @@
"subperiod"
],
"niryaana_shoola": [
"start_sign",
"direction",
"years",
"subperiod"
@@ -1909,32 +1907,32 @@
},
"niryaana_shoola": {
"sign_indexes": [
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
8,
7,
6,
5,
4,
3,
2,
1,
0,
1
11,
10
],
"years": [
7,
9,
8,
7,
9,
8,
7,
9,
8,
7,
9,
9,
9,
9,
9,
9,
9,
9,
9
8
]
},
"lagna_kendradi": {
@@ -1992,7 +1990,6 @@
"subperiod"
],
"niryaana_shoola": [
"start_sign",
"direction",
"years",
"subperiod"
@@ -2568,34 +2565,8 @@
]
},
"niryaana_shoola": {
"sign_indexes": [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
0
],
"years": [
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9,
9
]
"sign_indexes": [],
"years": []
},
"lagna_kendradi": {
"status": "omitted",
+9 -4
View File
@@ -147,10 +147,15 @@ def test_secondary_dashas_use_native_profiles_and_match_goel_method_1(reports) -
niryaana = modules["niryaana_shoola_dasha"]
assert navamsha["status"] == "parameter_sensitive"
assert navamsha["profile_id"] == "goel_method_1_v1"
assert niryaana == {
"execution_status": "blocked",
"reason": "口径更正中,见 TASK-niryaana-shoola-pvr-20261018",
}
# 原值:niryaana == {execution_status: blocked, reason: 口径更正中,见 TASK-niryaana-shoola-pvr-20261018}
# 新值:status parameter_sensitive,profile pvr_niryaana_shoola_v1,periods 是 PVR 主运
# 原因:#84 用真结果换掉 #81 的占位。合盘校验器仍把这一行固定判为不适用。
assert niryaana["status"] == "parameter_sensitive"
assert niryaana["profile_id"] == "pvr_niryaana_shoola_v1"
assert niryaana["periods"] == niryaana["native_result"]["periods"]
assert niryaana["native_result"]["status"] == "listed"
assert "antardasha" not in niryaana["periods"][0]
assert sum(row["years"] for row in niryaana["periods"]) == 96
assert navamsha["native_result"]["status"] == "listed"
first = navamsha["periods"][0]
d9 = modules["varga_full"]["D9_Navamsa"]
+146 -7
View File
@@ -13,6 +13,9 @@ from scripts.rashi_dashas_native import (
drig_aspect_sequence,
navamsha_sign,
navamsha_years,
niryaana_shoola_applicability,
niryaana_shoola_mahadashas,
select_niryaana_shoola_start_sign,
)
from scripts.rashi_dasha_report import render_rashi_dasha_chapter
@@ -63,15 +66,23 @@ def test_goel_short_method_matches_the_printed_totals_and_direct_sthira() -> Non
assert "pratyantar" not in aquarius
def test_same_goel_chart_niryaana_uses_short_bala_not_the_printed_jaimini_start() -> None:
# Printed Jaimini rashi bala on this chart starts Pisces. Short-method totals tie,
# and the karaka rank of the lords selects Virgo. The report keeps the short method.
def test_same_goel_chart_niryaana_uses_pvr_not_the_shoola_rule() -> None:
# 原值:首运 Virgo,十二段都是 9 年,并有从 Virgo 起的子运。
# 新值:profile pvr_niryaana_shoola_v1,首运 Aries、顺行,年数 7/8/9,没有子运。
# 原因:产品 2026-10-18 推翻 BUG-1283 所选的 goel_nirayana_shoola_v1。那套和 Shoola 是同一张表。
packet = calculate_rashi_dashas(date(2001, 1, 1), 5 * 30 + 1.55, GOEL_LONS)
niryaana = packet["niryaana_shoola"]
assert niryaana["periods"][0]["sign"] == "Virgo"
assert [row["years"] for row in niryaana["periods"]] == [9] * 12
assert niryaana["periods"][0]["antardasha"][0]["sign"] == "Virgo"
assert niryaana["periods"][0]["antardasha"][0]["end"] == "2001-10-01"
shoola = packet["shoola"]
assert niryaana["profile_id"] == "pvr_niryaana_shoola_v1"
assert niryaana["status"] == "listed"
assert niryaana["direction"] == "forward"
assert niryaana["periods"][0]["sign"] == "Aries"
assert [row["years"] for row in niryaana["periods"][:3]] == [7, 8, 9]
assert "antardasha" not in niryaana["periods"][0]
assert (
niryaana["periods"][0]["sign"] != shoola["periods"][0]["sign"]
or [row["years"] for row in niryaana["periods"]] != [row["years"] for row in shoola["periods"]]
)
def test_drig_aspect_order_for_a_cancer_lagna_and_narayana_style_years() -> None:
@@ -152,7 +163,128 @@ def test_report_modules_do_not_call_the_old_shoola_or_niryaana_helpers() -> None
assert all(not item.split(".")[0] == "jhora" for item in imported)
def test_niryaana_shoola_starts_from_the_stronger_second_or_eighth() -> None:
taurus = select_niryaana_shoola_start_sign(ascendant_longitude=0.0, planet_longitudes={"Mars": 31.0})
scorpio = select_niryaana_shoola_start_sign(ascendant_longitude=0.0, planet_longitudes={"Mars": 211.0})
assert taurus["selected_sign"] == "Taurus"
assert scorpio["selected_sign"] == "Scorpio"
taurus_rows = niryaana_shoola_mahadashas(date(2000, 1, 1), taurus["selected_sign_idx"])
scorpio_rows = niryaana_shoola_mahadashas(date(2000, 1, 1), scorpio["selected_sign_idx"])
assert [row["sign"] for row in taurus_rows[:2]] == ["Taurus", "Aries"]
assert [row["sign"] for row in scorpio_rows[:2]] == ["Scorpio", "Libra"]
def test_niryaana_shoola_odd_signs_go_forward_and_even_signs_go_backward() -> None:
even_seed = niryaana_shoola_applicability(ascendant_longitude=0.0, second_strength=2.0, eighth_strength=1.0)
odd_seed = select_niryaana_shoola_start_sign(ascendant_longitude=45.0, planet_longitudes={"Mars": 61.0})
assert even_seed["starting_sign"] == "Taurus"
assert even_seed["direction"] == "reverse"
assert odd_seed["selected_sign"] == "Gemini"
odd_rows = niryaana_shoola_mahadashas(date(2000, 1, 1), odd_seed["selected_sign_idx"])
assert [row["sign"] for row in odd_rows[:2]] == ["Gemini", "Cancer"]
assert [row["years"] for row in odd_rows[:3]] == [9, 7, 8]
def test_niryaana_shoola_given_strengths_match_the_upstream_mahadasha_golden() -> None:
# Golden copied from upstream 9f08b57b tests/test_niryaana_shoola_dasha.py
# test_niryaana_shoola_uses_modality_years_and_parity_direction.
picked = niryaana_shoola_applicability(
ascendant_longitude=15.0, second_strength=2.0, eighth_strength=1.0,
)
rows = niryaana_shoola_mahadashas(date(2000, 1, 1), picked["starting_sign_index"])
assert [row["sign"] for row in rows[:3]] == ["Taurus", "Aries", "Pisces"]
assert [row["years"] for row in rows[:3]] == [8, 7, 9]
assert [row["sign"] for row in rows] == [
"Taurus", "Aries", "Pisces", "Aquarius", "Capricorn", "Sagittarius",
"Scorpio", "Libra", "Virgo", "Leo", "Cancer", "Gemini",
]
assert [row["years"] for row in rows] == [8, 7, 9, 8, 7, 9, 8, 7, 9, 8, 7, 9]
assert sum(row["years"] for row in rows) == 96
assert all("antardasha" not in row for row in rows)
def test_two_public_charts_match_the_upstream_start_signs() -> None:
# Claude 10-18, upstream 9f08b57b: one chart starts in Sagittarius, one in Aquarius.
# These two public charts are the pair. Sequences are the PVR mahadashas from that start.
expected = {
"brad_pitt": (
"Sagittarius",
["Sagittarius", "Capricorn", "Aquarius", "Pisces", "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio"],
[9, 7, 8, 9, 7, 8, 9, 7, 8, 9, 7, 8],
),
"jolie": (
"Aquarius",
["Aquarius", "Pisces", "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn"],
[8, 9, 7, 8, 9, 7, 8, 9, 7, 8, 9, 7],
),
}
sources = {
"brad_pitt": ROOT / "tests/indastro_cases.json",
"jolie": ROOT / "tests/celebrity_cases.json",
}
from scripts.jyotish_engine import compute_chart_data
for case_id, (start, signs, years) in expected.items():
rows = json.loads(sources[case_id].read_text(encoding="utf-8"))
birth = next(row for row in rows if row["id"] == case_id)
chart, _asc, _jd, _ayanamsa = compute_chart_data(
birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"],
birth["lat"], birth["lon"], birth["tz"], second=birth.get("second") or 0,
ayanamsa_name="lahiri",
)
longitudes = {
name: float(row["degree_raw"])
for name, row in chart["planets"].items()
if isinstance(row, dict) and "degree_raw" in row
}
section = calculate_rashi_dashas(
chart["birth_info"]["date"], float(chart["ascendant"]["degree_raw"]), longitudes,
)["niryaana_shoola"]
assert section["status"] == "listed"
assert section["direction"] == "forward"
assert [row["sign"] for row in section["periods"]] == signs
assert section["periods"][0]["sign"] == start
assert [row["years"] for row in section["periods"]] == years
assert all("antardasha" not in row for row in section["periods"])
def test_niryaana_shoola_tie_stays_off_the_table() -> None:
tied = {name: 3 * 30 + 10.0 for name in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")}
section = calculate_rashi_dashas(date(2001, 1, 1), 0.5, tied)["niryaana_shoola"]
assert section["status"] == "omitted"
assert section["periods"] == []
assert "本轮不上表" in section["reason_zh"]
assert "二宫与八宫力量相同" in section["reason_zh"]
def test_niryaana_shoola_counts_both_nodes_and_ignores_outer_planets() -> None:
# Same placements as upstream 258e08e3.
occupancy = select_niryaana_shoola_start_sign(
ascendant_longitude=0.0,
planet_longitudes={"Rahu": 31.0, "Ketu": 211.0, "Pluto": 31.0},
)
aspects = select_niryaana_shoola_start_sign(
ascendant_longitude=0.0,
planet_longitudes={"Rahu": 1.0, "Ketu": 181.0},
)
assert occupancy["included_bodies"][-2:] == ["Rahu", "Ketu"]
assert occupancy["candidates"][1]["occupants"] == ["Rahu"]
assert occupancy["candidates"][7]["occupants"] == ["Ketu"]
assert "Pluto" not in occupancy["candidates"][1]["occupants"]
assert aspects["candidates"][1]["rasi_aspecting_planets"] == ["Ketu"]
assert aspects["candidates"][7]["rasi_aspecting_planets"] == ["Rahu"]
without_outer = select_niryaana_shoola_start_sign(ascendant_longitude=0.0, planet_longitudes={"Mars": 31.0})
with_outer = select_niryaana_shoola_start_sign(
ascendant_longitude=0.0, planet_longitudes={"Mars": 31.0, "Uranus": 211.0},
)
assert without_outer["selected_sign"] == with_outer["selected_sign"] == "Taurus"
assert "Uranus" not in with_outer["candidates"][7]["occupants"]
def test_pyjhora_fixture_diffs_stay_on_the_recorded_steps() -> None:
# 原值:niryaana_shoola 的 native 行与 Shoola 一样,从上升或第七宫起、每段 9 年,并带子运差异。
# 新值:同一键改为 PVR 主运的星座和年数;diff_steps 按新表相对 PyJHora 重记。
# 原因:产品 2026-10-18 改口径。PyJHora 对照行没有重跑,只改我们自己的期望。
payload = json.loads(FIXTURE.read_text(encoding="utf-8"))
assert payload["pyjhora_version"] == "4.8.7"
assert payload["generator_python_has_pyjhora"] is True
@@ -227,6 +359,10 @@ def test_jobs_chapter_names_the_method_and_leaves_kendradi_out() -> None:
text = render_rashi_dasha_chapter(packet)
for title in ("Sthira", "Niryaana Shoola", "Drig", "Navamsha", "Shoola", "Chara"):
assert f"### {title}" in text
niryaana = text.split("### Niryaana Shoola", 1)[1].split("### ", 1)[0]
assert "pvr_niryaana_shoola_v1" in niryaana
assert "子运规则未定,暂不列出。" in niryaana
assert "| 序号 | 主运 | 子运 |" not in niryaana
assert "本站选用的方法:" in text
assert "未与外部软件逐一核对" in text
assert "K.N. Rao" in text
@@ -236,6 +372,9 @@ def test_jobs_chapter_names_the_method_and_leaves_kendradi_out() -> None:
assert "| 序号 | 星座 |" not in kendradi
english = render_rashi_dasha_chapter({**packet, "report_language": "en"})
assert "Method used on this site:" in english
english_niryaana = english.split("### Niryaana Shoola Dasha", 1)[1].split("### ", 1)[0]
assert "pvr_niryaana_shoola_v1" in english_niryaana
assert "Child periods stay off this table" in english_niryaana
assert "This report does not include Lagna Kendradi" in english
assert "not checked one-by-one against external software." in english
leaked = [char for char in english if "\u4e00" <= char <= "\u9fff"]