Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d2506c8c1 | ||
|
|
b827901c1d | ||
|
|
0a760364aa | ||
|
|
2ee2b05999 | ||
|
|
3e37271728 | ||
|
|
7f0c5f93fb | ||
|
|
e7cab24c1a | ||
|
|
f67d266129 | ||
|
|
1ba473970f | ||
|
|
25c51ff0d0 | ||
|
|
56a3c08145 |
@@ -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 的算法和返回字段不变。
|
||||
|
||||
@@ -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"` 字符串,隐私测试通过。
|
||||
@@ -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
@@ -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%),差距不明显的数字只会被读成「分出了高下」。
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user