diff --git a/CHANGELOG.md b/CHANGELOG.md index 391f8d47..3114c0cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-09-16 — 生时校正:问完所有线才出卡,时间窗不再硬贴一个领域 + +出卡时机只看一件事:还有没有题可问。引导窗口题、跳过线的重问、还没问过的领域,全部问完之后才出目前范围卡;用户明确说「没有了」仍然立刻给结果。之前门槛一达标就会跳过剩下的采集线,现在不会了。反过来,题全问完了就照现行规则给卡和采用按钮,不会因为范围还宽而把你晾着。 + +引导题也改了问法:大运边界只说得出「哪一段时间」,说不出「哪一类事」,所以这类题不再硬贴一个领域,改成「2018 年 3 到 5 月之间,有没有什么事,比如搬家、换工作?」——答「这段没有」只关这一个时间窗,不会再让你那段时间的别的事永远问不到。用选择器录入后,账本记的是你选的那一类(「2019 年 3 月,感情方面有一件事」),不再是题干里的三选一列表。Skill 升到 10.0.28,旧的校正会话仍按当时绑定的版本打开。 + ## 2026-09-16 — 四个页面共用同一个侧栏,换页不再重读列表、不再自己展开 星盘、星历、我的报告和报告阅读页的侧栏,此前是另写的一份,会话行比对话页矮一截、少了当前项的那条竖标记,行尾还空着一块给一个根本不存在的菜单按钮;页脚也是另一种。现在全站只有一个侧栏:在这四个页面上它不带重命名、归档、删除和账户菜单,除此之外每一行、每一处间距都和对话页一模一样。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 83cde650..373d153c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -11424,6 +11424,7 @@ - 根因:golden 存了全精度浮点并整体相等。这是 BUG-712 的同一形状。BUG-712 的防复发只落在 `ephemeris_events` 那一处(量化到 6 位再比),没有仓库级守卫,BUG-721 新写的 rectification golden 重蹈覆辙。 - 修复:只改测试,不动 `scripts/`。主证据改成同进程差分:同一批 static contexts,A 带四层缓存、B 把 `ashtakavarga_result` / `shadbala_result` / `vimshottari_timeline` / `narayana_periods` 置 `None`,`compute_event_candidate_rows` 输出严格相等。golden 改成分档比较:离散字段严格相等;浮点用实测最大漂移 1.1e-3 推出的容差(绝对 2e-3 与相对 5e-4 取更宽者)。禁止调用 `write_golden()` 更新那份 JSON。 - 验证:同进程 A/B 逐字相等,且 B 的 `calc_shadbala` 次数显著高于 A(本机 6 vs 0)。反向:把某一层缓存换成全零 shadbala 对象后不再相等。golden 分档比较通过;把 golden 浮点改 1e-2 必须红;把离散字段 `time` 改掉必须红。`pytest tests/test_rectification_engine_memoization.py` 与 `pytest tests/test_rectification_*.py` 0 failed。golden JSON `git diff` 无改动。 +- 2026-09-16:`decision_receipt` 新增 `guided_collect_windows` 后重生成 golden,diff 仅此一键(62 行),未放宽 `_assert_tiered_equal`、未调用 `write_golden()`;同时把 `_golden_payload()` 的 `date.today()` 冻结,见 BUG-747。 - 防复发:任何 golden 比较都不得对浮点做整体 `==`;浮点必须量化或带容差,容差数值要有实测依据并写在注释里;能在同进程内做差分证明的命题,不得用跨机 golden 代替。 - 相关记录:BUG-712、BUG-721 - 复发自:BUG-712 @@ -11664,3 +11665,100 @@ - 相关记录:BUG-745 - 复发自:无 - 修复版本:待发布 + +## BUG-747 | 校正 receipt 新增 `guided_collect_windows` 键,记忆化 golden 门禁红 + +- 状态:resolved +- 首次发现:2026-09-16 +- 最近更新:2026-09-16 +- 影响面:`scripts/rectification/decision_policy.py`、`tests/test_rectification_engine_memoization.py`、`tests/golden/rectification_engine_memoization_v1.json` +- 用户现象:用户看不见。`run_quality_gate.py` 的 `CORE_PYTEST_TARGETS` 含 `tests/test_rectification_*.py`,`test_score_candidates_matches_baseline_golden` 红,门禁不放行、镜像不发布,staging 停在上一个 SHA。 +- 触发条件:`build_decision_receipt` 往 receipt 里加了 `guided_collect_windows`,golden 里没有这个键;`_assert_tiered_equal` 对 receipt 做键集合严格相等。 +- 根因:形状变更没有同步 golden。BUG-733 的分档比较与「禁止 `write_golden()`」是对**浮点漂移**的防线,它按设计拦住了这次的键集合差异——拦截生效了,是上一轮执行方没有跑这个文件(进度记录里 Python 只跑了四个定向文件,没跑 `tests/test_rectification_*.py` 全量)。 +- 修复:golden 只追加 `guided_collect_windows` 一个键(`git diff` 只有这一处、62 行),不放宽 `_assert_tiered_equal`、不调用 `write_golden()`。另外把 `_golden_payload()` 里的 `date.today()` 冻结到 `FROZEN_TODAY`:窗口年份取自墙钟,不冻结的话这份 golden 会在跨年时自己变红(BUG-733 同一族的隐患)。 +- 验证:`.venv/bin/python -m pytest tests/test_rectification_*.py tests/test_event_probes_guided_windows.py -q` → 193 passed;golden 的 `git diff --stat` 为 `1 file changed, 62 insertions(+)`。 +- 防复发:改 `decision_receipt` 形状的轮次必须跑 `tests/test_rectification_*.py` 全量,不能只跑定向文件;golden 的任何字段都不得依赖墙钟。 +- 相关记录:BUG-733、BUG-712、BUG-721、BUG-740 +- 复发自:BUG-733(同一份 golden;旧防线拦住了,未被执行) +- 修复版本:待发布 + +## BUG-748 | 16 条既有校正断言变红却被写成「已按三栏改」 + +- 状态:resolved +- 首次发现:2026-09-16 +- 最近更新:2026-09-16 +- 影响面:`frontend/tests/rectification-*.test.ts`(11 个文件)、`docs/tasks/PROGRESS-rectification-precision-gate-guided-collect-20260916.md` +- 用户现象:用户看不见。`npm test` 从基线 3372 条 / 31 红变成 3391 条 / 47 红,新增的 16 红全在校正套件,没有一个文件被改过。 +- 触发条件:上一轮只跑了本单新增与相关的定向套件,全量 `npm test` 因本机无 Docker / 并行 OOM 被判定为「不能当回归清单」,于是没有与基线逐条比对。 +- 根因:AGENTS §7.3「测试总数不得低于开工时实测、改断言必须写三栏」被当成只约束**主动修改**的断言;被代码变更**打红**的既有断言没有走同一条流程。无 Docker 只影响 31 条固定清单,其余部分照样可比。 +- 修复:本轮把 16 条逐条处置(外加 D5/D6 连锁打红的另外 25 条,见 BUG-751),每条在测试里写「原值 / 新值 / 原因」三栏;不删测试、不 skip。 +- 验证:`npm test` → 3396 条 / 31 红,红清单与基线 31 条逐条同名(无 Docker 与 `[eval]` 路径别名那一组)。 +- 防复发:无 Docker 时的正确做法是「与基线逐条比对失败清单」,不是放弃全量;进度记录里不得把没跑过的套件写成已处置。 +- 相关记录:BUG-751、BUG-740 +- 复发自:无 +- 修复版本:待发布 + +## BUG-749 | 引导窗口的领域是轮询贴上去的,一个窗口只问一个随机领域 + +- 状态:resolved +- 首次发现:2026-09-16 +- 最近更新:2026-09-16 +- 影响面:`scripts/rectification/event_probes.py` `guided_collect_windows`、`collection-question-pool.ts` `guidedWindowPrompt` / `guidedWindowPool` / `windowAlreadyAsked`、`event-date-entry-card.tsx` +- 用户现象:「2018 年 3 到 5 月之间,有没有入职、换工作或职责变重?」答「这段没有」之后,这个时间窗再也不会出现——用户那段时间的搬家或恋爱永远问不到。 +- 触发条件:边界来自 Vimshottari 或那罗延本命轨道(与领域无关),引擎按 `fallback[unlayered_index % len(fallback)]` 轮流贴一个领域;前端 `windowAlreadyAsked` 以(年, 月区间, 领域)为键,而同一窗口只会以那一个领域出现一次。 +- 根因:把「哪一段时间能切开候选」和「这段时间发生了哪一类事」混成一个字段。只有 `nara:d9` / `nara:d10` 两条轨道真的带领域。 +- 修复:无领域轨道的窗口 `domain = "any"`,题干写「YYYY 年 M 到 M 月之间,有没有什么事,比如<开放领域口语 2~3 个>?」;口语从 `KIND_ORAL` 表取,剔除已拒答与已覆盖的领域,决策层不按领域写 `if`。`windowAlreadyAsked` 的键去掉领域,只看年 + 月区间。A 之后录入卡的芯片默认第一个仍开放的领域。`nara:d9` / `nara:d10` 保持固定领域;该领域被拒答时也退回 `any`。 +- 验证:`tests/test_event_probes_guided_windows.py`(真实引擎 20 分钟窗 golden,7 passed)断言无领域轨道窗口是 `any`、d9/d10 保留固定领域、拒答领域退回 `any`;`frontend/tests/rectification-guided-collect-20260916.test.ts` 断言开放题干的口语列表 ≤3 条、同一窗口换领域标签也不重问、芯片默认第一开放领域。 +- 防复发:窗口的 `domain` 只能来自真正带领域的轨道;一个边界窗口是一个问题,已问判定不得把领域并进键里。 +- 相关记录:BUG-740、BUG-741、BUG-751 +- 复发自:无 +- 修复版本:待发布 + +## BUG-750 | 录入卡提交的是选项列表拼成的句子,账本 summary 变成三选一 + +- 状态:resolved +- 首次发现:2026-09-16 +- 最近更新:2026-09-16 +- 影响面:`frontend/src/components/event-date-entry-card.tsx` `formatEventDateEntryMessage` +- 用户现象:用类型芯片 + 年/月选择器提交一件事后,账本里记的是「2019 年 3 月,开始认真关系、分手或结婚」——一句三选一列表,而不是用户选的那一类。 +- 触发条件:引导窗口题答「有,我来填时间」后从录入卡提交。 +- 根因:提交文本直接拼 `KIND_ORAL[domain]`,而 `KIND_ORAL` 是**题干用的例子列表**,不是事件描述。 +- 修复:改成「YYYY 年 M 月(D 日),<领域标签>方面有一件事」,标签取 `RANGE_DELIVERY_DOMAIN_LABEL`;文本里不再出现「或」。 +- 验证:`frontend/tests/rectification-guided-collect-20260916.test.ts` 断言提交文本与「不含『或』」。 +- 防复发:任何写进账本的用户文本都不得复用题干的例子列表。 +- 相关记录:BUG-740、BUG-749 +- 复发自:无 +- 修复版本:待发布 + +## BUG-751 | 精度门槛被当成充分条件,把「线没问完不出卡」一起短路 + +- 状态:resolved +- 首次发现:2026-09-16 +- 最近更新:2026-09-16 +- 影响面:`core/rectification-decision.ts` `mayDeliverOnPrecision` / `decideRectification`、`v9/decision-from-dossier.ts` `narrowingExhaustion`、Skill §流程 +- 用户现象:门槛达标时,刷新与定向补事都还没问完就直接出交付卡;反过来,任何没有传这两个 flag 的调用(`decideConversationalSession`、`decideAfterInferenceChange` 的无 state 分支)因为「缺省即放行」而完全绕开了 BUG-654 / BUG-656 的「刷新与定向线未穷尽不得交付」。另外 `guidedCollectExhausted()` 不含 `refreshExhausted`:引导池空、刷新还没试过,也会出卡。 +- 触发条件:`precisionGateMet` 或 `guidedCollectExhausted` 任一为真(或两者都缺省)。 +- 根因:`mayDeliverOnPrecision` 写成了「或」,并且被塞进 `stillNeedNarrowing` 与 `datedMethodCollectOpen` 两条采集分支做短路,等于用门槛去撤销采集条件。 +- 修复(产品 2026-09-16 决策 D2 + D6):门槛只在**还有题可问时**挡住出卡。flag 全部缺省时门槛不参与,采集分支回到 `317e9f18` 的判断;flag 传入时交付条件 = `userStopped || (guidedCollectExhausted && refreshExhausted !== false && targetedCollectExhausted !== false)`——即 `userStopped || 所有题源都问完`。题源都空了就是 D2 说的「用户确实补不出来」,卡片、采用按钮、三句正文照现行规则给,**不加「未达门槛」标注**。撤回两处 `!mayDeliverOnPrecision` 短路,门槛只加在最终交付分支。`narrowingExhaustion` 计算的 `guidedCollectExhausted` 现在含 `refreshExhausted`。`precisionGateMet` 保留计算,并新挂到 `RectificationDecision.precisionGateMet` 与 `publicNextAction().precision_gate_met` 上报,只是不再单独决定时机。Skill §流程改成「所有线(含引导窗口题、跳过线重问、未覆盖领域题)问完后,或用户说没有了后,才交付目前范围」,bump 到 10.0.28。 +- **口径提醒**:按 D2 + D6,10 分钟门槛**不改变出卡时机**,本轮实际生效的是题源变多(引导窗口题 + 跳过线重问 + 七条线全部轮到)、线问完才出。 +- 验证:`frontend/tests/rectification-precision-gate-20260916.test.ts` 新增五条——引导池空但 `refreshExhausted=false` 不交付、门槛达标但定向线未问完继续问、所有线问完但门槛未达**照现行规则出卡(含采用)**、门槛值在采集出口也照常上报、helper 路径上报 null;`rectification-decision-authority.test.ts` 的公开字段深比较跟上 `precision_gate_met`。撤回短路后连锁打红的 41 条既有断言(16 条来自 BUG-748 的基线红 + 25 条本轮新红)全部按「原值 / 新值 / 原因」三栏处置,均为 D5「七条线全部轮到」与 D6「问完再出」的预期变化。 +- 防复发:门槛不得出现在任何采集分支的条件里,也不得成为「题源已空仍不出卡」的理由(那会造出没有题也没有卡的死角,违反 BUG-652);缺省 flag 只能表示「门槛不参与」,不能表示「放行」。 +- 相关记录:BUG-654、BUG-656、BUG-740、BUG-748、BUG-749 +- 复发自:BUG-654(刷新与定向线未穷尽不得交付,被门槛短路绕开) +- 修复版本:待发布 + +## BUG-752 | 离线回放注入的事件与真值边界无关,「0/20」不能当结论 + +- 状态:resolved +- 首次发现:2026-09-16 +- 最近更新:2026-09-16 +- 影响面:`scripts/research/guided_collect_holdout_replay.py`、`docs/research/guided_collect_holdout_2026_09_16.json` +- 用户现象:用户看不见。研究结论写成「没有一例靠引导件达标」,被当作引导题源无效的证据。 +- 触发条件:`synthetic_event` 一律取 `window["month_lo"]` 作为事件月,领域取轮询值,与真值候选在该窗口上的边界日期无关。 +- 根因:任务书要求注入「真值方向」的带年月事件,脚本注入的是窗口左端点。落在窗口里但不在真值候选的边界上,对打分几乎没有方向性,所以量到的是合成事件的噪声,不是题源的信息量。 +- 修复:按窗口的(轨道, index)取回每个候选自己的边界日期,`truth` 组注入真值候选的那一天所在月,另跑一组 `opposite`(离真值最远的候选的同一边界)做对照;三档半径都跑。汇总新增 `met_after_six_only` / `met_via_guided` / `median_events_via_guided`,把「六题后本来就达标」和「靠引导件补上」分开数。 +- 验证:见 `docs/tasks/PROGRESS-rectification-precision-gate-guided-collect-fix-20260916.md` 的数字表与 `docs/research/guided_collect_holdout_2026_09_16.json`。这不是合入门槛。 +- 防复发:离线回放的合成证据必须能说清「注入在谁的边界上」;只报总达标数、不分「本来就达标 / 靠注入达标」的数字不得写进结论。 +- 相关记录:BUG-740、BUG-749 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/research/guided_collect_holdout_2026_09_16.json b/docs/research/guided_collect_holdout_2026_09_16.json index f8da9059..91f0cb23 100644 --- a/docs/research/guided_collect_holdout_2026_09_16.json +++ b/docs/research/guided_collect_holdout_2026_09_16.json @@ -1,1721 +1,4394 @@ -{ - "generated_at": "2026-09-16", - "holdout": "references/real_case_calibration/minute_rectification_holdout_v4.json", - "ask_count": 6, - "delivery_max_width_minutes": 10, - "tie_percent": 3, - "elapsed_s": 550.1, - "summaries": [ - { - "radius": 10, - "n": 20, - "met": 3, - "median_events_to_gate": 0, - "unmet": 17, - "errors": 0 - }, - { - "radius": 30, - "n": 20, - "met": 1, - "median_events_to_gate": 0, - "unmet": 19, - "errors": 0 - }, - { - "radius": 60, - "n": 20, - "met": 0, - "median_events_to_gate": null, - "unmet": 20, - "errors": 0 - } - ], - "rows": [ - { - "case_id": "barack_obama_1961_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 15, - "tied_for_first": false, - "gap": 2, - "percents": [ - 23, - 21, - 20 - ] - }, - "final": { - "met": false, - "width": 15, - "tied_for_first": false, - "gap": 3, - "percents": [ - 23, - 20, - 19 - ] - } - }, - { - "case_id": "barack_obama_1961_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 39, - "tied_for_first": false, - "gap": 0, - "percents": [ - 16, - 16, - 14 - ] - }, - "final": { - "met": false, - "width": 39, - "tied_for_first": false, - "gap": 0, - "percents": [ - 14, - 14, - 13 - ] - } - }, - { - "case_id": "barack_obama_1961_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 25, - "tied_for_first": false, - "gap": 0, - "percents": [ - 16, - 16, - 15 - ] - }, - "final": { - "met": false, - "width": 25, - "tied_for_first": false, - "gap": 1, - "percents": [ - 14, - 13, - 13 - ] - } - }, - { - "case_id": "angelina_jolie_1975_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 19, - "tied_for_first": false, - "gap": 1, - "percents": [ - 22, - 21, - 20 - ] - }, - "final": { - "met": false, - "width": 13, - "tied_for_first": false, - "gap": 1, - "percents": [ - 27, - 26, - 25 - ] - } - }, - { - "case_id": "angelina_jolie_1975_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 61, - "tied_for_first": false, - "gap": 1, - "percents": [ - 11, - 10, - 10 - ] - }, - "final": { - "met": false, - "width": 61, - "tied_for_first": false, - "gap": 1, - "percents": [ - 14, - 13, - 13 - ] - } - }, - { - "case_id": "angelina_jolie_1975_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 53, - "tied_for_first": false, - "gap": 0, - "percents": [ - 12, - 12, - 12 - ] - }, - "final": { - "met": false, - "width": 53, - "tied_for_first": false, - "gap": 0, - "percents": [ - 15, - 15, - 15 - ] - } - }, - { - "case_id": "tiger_woods_1975_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 21, - "tied_for_first": false, - "gap": 1, - "percents": [ - 18, - 17, - 17 - ] - }, - "final": { - "met": false, - "width": 21, - "tied_for_first": false, - "gap": 0, - "percents": [ - 18, - 18, - 17 - ] - } - }, - { - "case_id": "tiger_woods_1975_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 27, - "tied_for_first": false, - "gap": 0, - "percents": [ - 15, - 15, - 15 - ] - }, - "final": { - "met": false, - "width": 27, - "tied_for_first": false, - "gap": 0, - "percents": [ - 15, - 15, - 15 - ] - } - }, - { - "case_id": "tiger_woods_1975_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 61, - "tied_for_first": false, - "gap": 0, - "percents": [ - 9, - 9, - 8 - ] - }, - "final": { - "met": false, - "width": 61, - "tied_for_first": false, - "gap": 0, - "percents": [ - 8, - 8, - 8 - ] - } - }, - { - "case_id": "elizabeth_taylor_1932_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 13, - "tied_for_first": false, - "gap": 1, - "percents": [ - 27, - 26, - 26 - ] - }, - "final": { - "met": false, - "width": 13, - "tied_for_first": false, - "gap": 0, - "percents": [ - 26, - 26, - 25 - ] - } - }, - { - "case_id": "elizabeth_taylor_1932_aa_v4_holdout", - "radius": 30, - "events_needed": 0, - "windows": 0, - "met": true, - "after_six": { - "met": true, - "width": 7, - "tied_for_first": false, - "gap": 10, - "percents": [ - 55, - 45 - ] - } - }, - { - "case_id": "elizabeth_taylor_1932_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 33, - "tied_for_first": false, - "gap": 0, - "percents": [ - 16, - 16, - 15 - ] - }, - "final": { - "met": false, - "width": 33, - "tied_for_first": false, - "gap": 0, - "percents": [ - 15, - 15, - 15 - ] - } - }, - { - "case_id": "elizabeth_montgomery_1933_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 7, - "tied_for_first": false, - "gap": 0, - "percents": [ - 34, - 34, - 33 - ] - }, - "final": { - "met": false, - "width": 7, - "tied_for_first": false, - "gap": 0, - "percents": [ - 34, - 34, - 32 - ] - } - }, - { - "case_id": "elizabeth_montgomery_1933_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 33, - "tied_for_first": false, - "gap": 0, - "percents": [ - 22, - 22, - 19 - ] - }, - "final": { - "met": false, - "width": 33, - "tied_for_first": false, - "gap": 0, - "percents": [ - 18, - 18, - 17 - ] - } - }, - { - "case_id": "elizabeth_montgomery_1933_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 55, - "tied_for_first": false, - "gap": 1, - "percents": [ - 12, - 11, - 10 - ] - }, - "final": { - "met": false, - "width": 55, - "tied_for_first": false, - "gap": 0, - "percents": [ - 12, - 12, - 11 - ] - } - }, - { - "case_id": "pablo_picasso_1881_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 0, - "met": false, - "after_six": { - "met": false, - "width": 21, - "tied_for_first": false, - "gap": 0, - "percents": [ - 17, - 17, - 17 - ] - }, - "final": { - "met": false, - "width": 21, - "tied_for_first": false, - "gap": 0, - "percents": [ - 17, - 17, - 17 - ] - } - }, - { - "case_id": "pablo_picasso_1881_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 0, - "met": false, - "after_six": { - "met": false, - "width": 61, - "tied_for_first": false, - "gap": 0, - "percents": [ - 7, - 7, - 7 - ] - }, - "final": { - "met": false, - "width": 61, - "tied_for_first": false, - "gap": 0, - "percents": [ - 7, - 7, - 7 - ] - } - }, - { - "case_id": "pablo_picasso_1881_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 0, - "met": false, - "after_six": { - "met": false, - "width": 1439, - "tied_for_first": false, - "gap": 0, - "percents": [ - 4, - 4, - 4 - ] - }, - "final": { - "met": false, - "width": 1439, - "tied_for_first": false, - "gap": 0, - "percents": [ - 4, - 4, - 4 - ] - } - }, - { - "case_id": "sigmund_freud_1856_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 0, - "met": false, - "after_six": { - "met": false, - "width": 21, - "tied_for_first": false, - "gap": 0, - "percents": [ - 18, - 18, - 18 - ] - }, - "final": { - "met": false, - "width": 21, - "tied_for_first": false, - "gap": 0, - "percents": [ - 18, - 18, - 18 - ] - } - }, - { - "case_id": "sigmund_freud_1856_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 0, - "met": false, - "after_six": { - "met": false, - "width": 61, - "tied_for_first": false, - "gap": 0, - "percents": [ - 8, - 8, - 8 - ] - }, - "final": { - "met": false, - "width": 61, - "tied_for_first": false, - "gap": 0, - "percents": [ - 8, - 8, - 8 - ] - } - }, - { - "case_id": "sigmund_freud_1856_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 0, - "met": false, - "after_six": { - "met": false, - "width": 121, - "tied_for_first": false, - "gap": 1, - "percents": [ - 5, - 4, - 4 - ] - }, - "final": { - "met": false, - "width": 121, - "tied_for_first": false, - "gap": 1, - "percents": [ - 5, - 4, - 4 - ] - } - }, - { - "case_id": "salma_hayek_1966_aa_v4_holdout", - "radius": 10, - "events_needed": 0, - "windows": 0, - "met": true, - "after_six": { - "met": true, - "width": 9, - "tied_for_first": false, - "gap": 7, - "percents": [ - 40, - 33, - 27 - ] - } - }, - { - "case_id": "salma_hayek_1966_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 37, - "tied_for_first": false, - "gap": 0, - "percents": [ - 11, - 11, - 11 - ] - }, - "final": { - "met": false, - "width": 31, - "tied_for_first": false, - "gap": 1, - "percents": [ - 12, - 11, - 11 - ] - } - }, - { - "case_id": "salma_hayek_1966_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 95, - "tied_for_first": false, - "gap": 0, - "percents": [ - 14, - 14, - 12 - ] - }, - "final": { - "met": false, - "width": 93, - "tied_for_first": false, - "gap": 0, - "percents": [ - 13, - 13, - 12 - ] - } - }, - { - "case_id": "steve_reich_1936_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 17, - "tied_for_first": false, - "gap": 4, - "percents": [ - 25, - 21, - 20 - ] - }, - "final": { - "met": false, - "width": 17, - "tied_for_first": false, - "gap": 2, - "percents": [ - 23, - 21, - 20 - ] - } - }, - { - "case_id": "steve_reich_1936_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 35, - "tied_for_first": false, - "gap": 0, - "percents": [ - 18, - 18, - 17 - ] - }, - "final": { - "met": false, - "width": 5, - "tied_for_first": false, - "gap": 0, - "percents": [ - 50, - 50 - ] - } - }, - { - "case_id": "steve_reich_1936_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 29, - "tied_for_first": false, - "gap": 0, - "percents": [ - 11, - 11, - 11 - ] - }, - "final": { - "met": false, - "width": 29, - "tied_for_first": false, - "gap": 0, - "percents": [ - 11, - 11, - 11 - ] - } - }, - { - "case_id": "albert_brooks_1947_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 21, - "tied_for_first": false, - "gap": 0, - "percents": [ - 16, - 16, - 15 - ] - }, - "final": { - "met": false, - "width": 21, - "tied_for_first": false, - "gap": 0, - "percents": [ - 15, - 15, - 15 - ] - } - }, - { - "case_id": "albert_brooks_1947_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 33, - "tied_for_first": false, - "gap": 4, - "percents": [ - 24, - 20, - 20 - ] - }, - "final": { - "met": false, - "width": 33, - "tied_for_first": false, - "gap": 2, - "percents": [ - 15, - 13, - 13 - ] - } - }, - { - "case_id": "albert_brooks_1947_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 107, - "tied_for_first": false, - "gap": 0, - "percents": [ - 9, - 9, - 8 - ] - }, - "final": { - "met": false, - "width": 119, - "tied_for_first": false, - "gap": 0, - "percents": [ - 6, - 6, - 6 - ] - } - }, - { - "case_id": "paul_ryan_1970_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 21, - "tied_for_first": false, - "gap": 0, - "percents": [ - 28, - 28, - 25 - ] - }, - "final": { - "met": false, - "width": 15, - "tied_for_first": false, - "gap": 3, - "percents": [ - 36, - 33, - 31 - ] - } - }, - { - "case_id": "paul_ryan_1970_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 53, - "tied_for_first": false, - "gap": 1, - "percents": [ - 11, - 10, - 10 - ] - }, - "final": { - "met": false, - "width": 53, - "tied_for_first": false, - "gap": 0, - "percents": [ - 9, - 9, - 9 - ] - } - }, - { - "case_id": "paul_ryan_1970_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 53, - "tied_for_first": false, - "gap": 1, - "percents": [ - 10, - 9, - 9 - ] - }, - "final": { - "met": false, - "width": 77, - "tied_for_first": false, - "gap": 1, - "percents": [ - 9, - 8, - 8 - ] - } - }, - { - "case_id": "tom_kennedy_1927_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 15, - "tied_for_first": false, - "gap": 11, - "percents": [ - 41, - 30, - 29 - ] - }, - "final": { - "met": false, - "width": 13, - "tied_for_first": false, - "gap": 8, - "percents": [ - 39, - 31, - 31 - ] - } - }, - { - "case_id": "tom_kennedy_1927_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 11, - "tied_for_first": false, - "gap": 14, - "percents": [ - 57, - 43 - ] - }, - "final": { - "met": false, - "width": 13, - "tied_for_first": false, - "gap": 10, - "percents": [ - 40, - 30, - 30 - ] - } - }, - { - "case_id": "tom_kennedy_1927_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 57, - "tied_for_first": false, - "gap": 1, - "percents": [ - 12, - 11, - 11 - ] - }, - "final": { - "met": false, - "width": 61, - "tied_for_first": false, - "gap": 1, - "percents": [ - 13, - 12, - 12 - ] - } - }, - { - "case_id": "andrew_windsor_1960_aa_v4_holdout", - "radius": 10, - "events_needed": 0, - "windows": 0, - "met": true, - "after_six": { - "met": true, - "width": 5, - "tied_for_first": false, - "gap": null, - "percents": [ - 100 - ] - } - }, - { - "case_id": "andrew_windsor_1960_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 27, - "tied_for_first": false, - "gap": 5, - "percents": [ - 38, - 33, - 29 - ] - }, - "final": { - "met": false, - "width": 11, - "tied_for_first": false, - "gap": 8, - "percents": [ - 54, - 46 - ] - } - }, - { - "case_id": "andrew_windsor_1960_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 11, - "tied_for_first": false, - "gap": 0, - "percents": [ - 50, - 50 - ] - }, - "final": { - "met": false, - "width": 65, - "tied_for_first": false, - "gap": 0, - "percents": [ - 27, - 27, - 23 - ] - } - }, - { - "case_id": "sean_lennon_1975_aa_v4_holdout", - "radius": 10, - "events_needed": 0, - "windows": 0, - "met": true, - "after_six": { - "met": true, - "width": 5, - "tied_for_first": false, - "gap": 20, - "percents": [ - 60, - 40 - ] - } - }, - { - "case_id": "sean_lennon_1975_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 19, - "tied_for_first": false, - "gap": 8, - "percents": [ - 33, - 25, - 22 - ] - }, - "final": { - "met": false, - "width": 25, - "tied_for_first": false, - "gap": 3, - "percents": [ - 23, - 20, - 20 - ] - } - }, - { - "case_id": "sean_lennon_1975_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 31, - "tied_for_first": false, - "gap": 1, - "percents": [ - 23, - 22, - 20 - ] - }, - "final": { - "met": false, - "width": 35, - "tied_for_first": false, - "gap": 0, - "percents": [ - 18, - 18, - 17 - ] - } - }, - { - "case_id": "joseph_kennedy_iii_1980_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 11, - "tied_for_first": false, - "gap": 2, - "percents": [ - 35, - 33, - 33 - ] - }, - "final": { - "met": false, - "width": 11, - "tied_for_first": false, - "gap": 1, - "percents": [ - 34, - 33, - 32 - ] - } - }, - { - "case_id": "joseph_kennedy_iii_1980_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 31, - "tied_for_first": false, - "gap": 1, - "percents": [ - 12, - 11, - 11 - ] - }, - "final": { - "met": false, - "width": 31, - "tied_for_first": false, - "gap": 1, - "percents": [ - 14, - 13, - 12 - ] - } - }, - { - "case_id": "joseph_kennedy_iii_1980_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 75, - "tied_for_first": false, - "gap": 1, - "percents": [ - 7, - 6, - 6 - ] - }, - "final": { - "met": false, - "width": 61, - "tied_for_first": false, - "gap": 1, - "percents": [ - 7, - 6, - 6 - ] - } - }, - { - "case_id": "kurt_cobain_1967_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 15, - "tied_for_first": false, - "gap": 6, - "percents": [ - 39, - 33, - 28 - ] - }, - "final": { - "met": false, - "width": 15, - "tied_for_first": false, - "gap": 1, - "percents": [ - 35, - 34, - 31 - ] - } - }, - { - "case_id": "kurt_cobain_1967_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 39, - "tied_for_first": false, - "gap": 1, - "percents": [ - 14, - 13, - 13 - ] - }, - "final": { - "met": false, - "width": 27, - "tied_for_first": false, - "gap": 2, - "percents": [ - 22, - 20, - 20 - ] - } - }, - { - "case_id": "kurt_cobain_1967_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 121, - "tied_for_first": false, - "gap": 0, - "percents": [ - 6, - 6, - 6 - ] - }, - "final": { - "met": false, - "width": 21, - "tied_for_first": false, - "gap": 0, - "percents": [ - 17, - 17, - 17 - ] - } - }, - { - "case_id": "john_robbins_1947_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 11, - "tied_for_first": false, - "gap": 8, - "percents": [ - 54, - 46 - ] - }, - "final": { - "met": false, - "width": 11, - "tied_for_first": false, - "gap": 2, - "percents": [ - 51, - 49 - ] - } - }, - { - "case_id": "john_robbins_1947_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 31, - "tied_for_first": false, - "gap": 0, - "percents": [ - 12, - 12, - 12 - ] - }, - "final": { - "met": false, - "width": 31, - "tied_for_first": false, - "gap": 1, - "percents": [ - 14, - 13, - 13 - ] - } - }, - { - "case_id": "john_robbins_1947_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 121, - "tied_for_first": false, - "gap": 0, - "percents": [ - 7, - 7, - 7 - ] - }, - "final": { - "met": false, - "width": 121, - "tied_for_first": false, - "gap": 0, - "percents": [ - 7, - 7, - 7 - ] - } - }, - { - "case_id": "chad_everett_1937_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 11, - "tied_for_first": false, - "gap": 0, - "percents": [ - 27, - 27, - 23 - ] - }, - "final": { - "met": false, - "width": 11, - "tied_for_first": false, - "gap": 0, - "percents": [ - 26, - 26, - 24 - ] - } - }, - { - "case_id": "chad_everett_1937_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 11, - "tied_for_first": false, - "gap": 1, - "percents": [ - 26, - 25, - 25 - ] - }, - "final": { - "met": false, - "width": 11, - "tied_for_first": false, - "gap": 1, - "percents": [ - 26, - 25, - 24 - ] - } - }, - { - "case_id": "chad_everett_1937_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 103, - "tied_for_first": false, - "gap": 0, - "percents": [ - 6, - 6, - 6 - ] - }, - "final": { - "met": false, - "width": 103, - "tied_for_first": false, - "gap": 0, - "percents": [ - 6, - 6, - 6 - ] - } - }, - { - "case_id": "bernd_eichinger_1949_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 21, - "tied_for_first": true, - "gap": 0, - "percents": [ - 19, - 19, - 18 - ] - }, - "final": { - "met": false, - "width": 21, - "tied_for_first": false, - "gap": 0, - "percents": [ - 18, - 18, - 18 - ] - } - }, - { - "case_id": "bernd_eichinger_1949_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 31, - "tied_for_first": true, - "gap": 0, - "percents": [ - 16, - 16, - 15 - ] - }, - "final": { - "met": false, - "width": 31, - "tied_for_first": false, - "gap": 1, - "percents": [ - 16, - 15, - 15 - ] - } - }, - { - "case_id": "bernd_eichinger_1949_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 51, - "tied_for_first": true, - "gap": 0, - "percents": [ - 14, - 14, - 14 - ] - }, - "final": { - "met": false, - "width": 51, - "tied_for_first": false, - "gap": 0, - "percents": [ - 13, - 13, - 13 - ] - } - }, - { - "case_id": "iwao_takamoto_1925_aa_v4_holdout", - "radius": 10, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 5, - "tied_for_first": false, - "gap": 0, - "percents": [ - 50, - 50 - ] - }, - "final": { - "met": false, - "width": 5, - "tied_for_first": false, - "gap": 2, - "percents": [ - 51, - 49 - ] - } - }, - { - "case_id": "iwao_takamoto_1925_aa_v4_holdout", - "radius": 30, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 59, - "tied_for_first": false, - "gap": 0, - "percents": [ - 11, - 11, - 9 - ] - }, - "final": { - "met": false, - "width": 61, - "tied_for_first": false, - "gap": 0, - "percents": [ - 9, - 9, - 9 - ] - } - }, - { - "case_id": "iwao_takamoto_1925_aa_v4_holdout", - "radius": 60, - "events_needed": null, - "windows": 6, - "met": false, - "after_six": { - "met": false, - "width": 17, - "tied_for_first": false, - "gap": 0, - "percents": [ - 23, - 23, - 19 - ] - }, - "final": { - "met": false, - "width": 13, - "tied_for_first": false, - "gap": 4, - "percents": [ - 28, - 24, - 24 - ] - } - } - ], - "errors": [] -} +{ + "generated_at": "2026-09-16", + "holdout": "references/real_case_calibration/minute_rectification_holdout_v4.json", + "ask_count": 6, + "guided_collect_limit": 6, + "injection": "truth = the true candidate's own boundary date for that window's track and index; opposite = the boundary of the remaining candidate furthest from the true minute", + "delivery_max_width_minutes": 10, + "tie_percent": 3, + "elapsed_s": 351.6, + "summaries": [ + { + "radius": 10, + "direction": "truth", + "n": 20, + "met": 3, + "met_after_six_only": 3, + "met_via_guided": 0, + "median_events_to_gate": 0, + "median_events_via_guided": null, + "median_windows": 6.0, + "unmet": 17, + "errors": 0 + }, + { + "radius": 10, + "direction": "opposite", + "n": 20, + "met": 3, + "met_after_six_only": 3, + "met_via_guided": 0, + "median_events_to_gate": 0, + "median_events_via_guided": null, + "median_windows": 6.0, + "unmet": 17, + "errors": 0 + }, + { + "radius": 30, + "direction": "truth", + "n": 20, + "met": 1, + "met_after_six_only": 1, + "met_via_guided": 0, + "median_events_to_gate": 0, + "median_events_via_guided": null, + "median_windows": 6.0, + "unmet": 19, + "errors": 0 + }, + { + "radius": 30, + "direction": "opposite", + "n": 20, + "met": 1, + "met_after_six_only": 1, + "met_via_guided": 0, + "median_events_to_gate": 0, + "median_events_via_guided": null, + "median_windows": 6.0, + "unmet": 19, + "errors": 0 + }, + { + "radius": 60, + "direction": "truth", + "n": 20, + "met": 0, + "met_after_six_only": 0, + "met_via_guided": 0, + "median_events_to_gate": null, + "median_events_via_guided": null, + "median_windows": 6.0, + "unmet": 20, + "errors": 0 + }, + { + "radius": 60, + "direction": "opposite", + "n": 20, + "met": 0, + "met_after_six_only": 0, + "met_via_guided": 0, + "median_events_to_gate": null, + "median_events_via_guided": null, + "median_windows": 6.0, + "unmet": 20, + "errors": 0 + } + ], + "rows": [ + { + "case_id": "barack_obama_1961_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 2, + "percents": [ + 23, + 21, + 20 + ] + }, + "final": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 2, + "percents": [ + 23, + 21, + 20 + ] + }, + "injected": [ + "19:24:2026-05-08", + "19:24:2026-10-03", + "19:24:2026-12-02", + "19:24:2024-12-10", + "19:24:2025-02-03", + "19:24:2025-07-09" + ] + }, + { + "case_id": "barack_obama_1961_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 2, + "percents": [ + 23, + 21, + 20 + ] + }, + "final": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 2, + "percents": [ + 23, + 21, + 20 + ] + }, + "injected": [ + "19:14:2026-06-01", + "19:14:2026-10-27", + "19:14:2026-12-26", + "19:14:2025-01-03", + "19:14:2025-02-27", + "19:14:2025-08-02" + ] + }, + { + "case_id": "barack_obama_1961_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 39, + "tied_for_first": false, + "gap": 0, + "percents": [ + 16, + 16, + 14 + ] + }, + "final": { + "met": false, + "width": 39, + "tied_for_first": false, + "gap": 0, + "percents": [ + 16, + 16, + 14 + ] + }, + "injected": [ + "19:24:2026-09-01", + "19:24:2026-12-02", + "19:24:2025-03-01", + "19:24:2025-09-13", + "19:24:2025-09-13", + "19:24:2025-10-01" + ] + }, + { + "case_id": "barack_obama_1961_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 39, + "tied_for_first": false, + "gap": 0, + "percents": [ + 16, + 16, + 14 + ] + }, + "final": { + "met": false, + "width": 39, + "tied_for_first": false, + "gap": 0, + "percents": [ + 16, + 16, + 14 + ] + }, + "injected": [ + "18:54:2026-09-01", + "18:54:2026-12-14", + "18:54:2025-03-01", + "18:54:2024-08-03", + "18:54:2024-08-03", + "18:54:2025-10-01" + ] + }, + { + "case_id": "barack_obama_1961_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 25, + "tied_for_first": false, + "gap": 0, + "percents": [ + 16, + 16, + 15 + ] + }, + "final": { + "met": false, + "width": 17, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 17 + ] + }, + "injected": [ + "19:24:2026-05-08", + "19:24:2026-09-01", + "19:24:2026-11-01", + "19:24:2026-12-07", + "19:24:2024-12-10", + "19:24:2025-06-15" + ] + }, + { + "case_id": "barack_obama_1961_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 25, + "tied_for_first": false, + "gap": 0, + "percents": [ + 16, + 16, + 15 + ] + }, + "final": { + "met": false, + "width": 25, + "tied_for_first": false, + "gap": 1, + "percents": [ + 16, + 15, + 15 + ] + }, + "injected": [ + "19:04:2026-06-25", + "19:04:2026-09-01", + "19:04:2026-11-01", + "19:04:2013-04-28", + "19:04:2025-01-27", + "19:04:2011-03-11" + ] + }, + { + "case_id": "angelina_jolie_1975_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 19, + "tied_for_first": false, + "gap": 1, + "percents": [ + 22, + 21, + 20 + ] + }, + "final": { + "met": false, + "width": 19, + "tied_for_first": false, + "gap": 1, + "percents": [ + 22, + 21, + 20 + ] + }, + "injected": [ + "09:09:2025-11-28", + "09:09:2025-12-28", + "09:09:2026-03-24", + "09:09:2026-04-19", + "09:09:2026-06-01", + "09:09:2026-07-01" + ] + }, + { + "case_id": "angelina_jolie_1975_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 19, + "tied_for_first": false, + "gap": 1, + "percents": [ + 22, + 21, + 20 + ] + }, + "final": { + "met": false, + "width": 19, + "tied_for_first": false, + "gap": 1, + "percents": [ + 22, + 21, + 20 + ] + }, + "injected": [ + "08:59:2026-01-06", + "08:59:2026-02-05", + "08:59:2026-05-02", + "08:59:2026-05-28", + "08:59:2026-07-10", + "08:59:2026-08-09" + ] + }, + { + "case_id": "angelina_jolie_1975_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 1, + "percents": [ + 11, + 10, + 10 + ] + }, + "final": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 1, + "percents": [ + 11, + 10, + 10 + ] + }, + "injected": [ + "09:09:2026-03-24", + "09:09:2026-09-17", + "09:09:2026-10-01", + "09:09:2026-11-01", + "09:09:2026-12-01", + "09:09:2024-12-15" + ] + }, + { + "case_id": "angelina_jolie_1975_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 1, + "percents": [ + 11, + 10, + 10 + ] + }, + "final": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 12, + 12, + 11 + ] + }, + "injected": [ + "08:39:2026-04-24", + "08:39:2026-10-27", + "08:39:2026-10-01", + "08:39:2026-11-01", + "08:39:2026-12-01", + "08:39:2025-01-05" + ] + }, + { + "case_id": "angelina_jolie_1975_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 53, + "tied_for_first": false, + "gap": 0, + "percents": [ + 12, + 12, + 12 + ] + }, + "final": { + "met": false, + "width": 53, + "tied_for_first": false, + "gap": 0, + "percents": [ + 12, + 12, + 12 + ] + }, + "injected": [ + "09:09:2025-09-15", + "09:09:2026-03-01", + "09:09:2026-06-01", + "09:09:2025-01-01", + "09:09:2025-03-01", + "09:09:2025-06-01" + ] + }, + { + "case_id": "angelina_jolie_1975_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 53, + "tied_for_first": false, + "gap": 0, + "percents": [ + 12, + 12, + 12 + ] + }, + "final": { + "met": false, + "width": 53, + "tied_for_first": false, + "gap": 0, + "percents": [ + 13, + 13, + 13 + ] + }, + "injected": [ + "08:37:2026-01-19", + "08:37:2026-03-01", + "08:37:2026-06-01", + "08:37:2025-01-01", + "08:37:2025-03-01", + "08:37:2025-06-01" + ] + }, + { + "case_id": "tiger_woods_1975_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 1, + "percents": [ + 18, + 17, + 17 + ] + }, + "final": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 17, + 17, + 17 + ] + }, + "injected": [ + "22:50:2026-03-28", + "22:50:2026-08-06", + "22:50:2026-12-01", + "22:50:2024-12-18", + "22:50:2025-01-22", + "22:50:2025-02-12" + ] + }, + { + "case_id": "tiger_woods_1975_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 1, + "percents": [ + 18, + 17, + 17 + ] + }, + "final": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 17, + 17, + 17 + ] + }, + "injected": [ + "22:40:2026-04-15", + "22:40:2026-08-24", + "22:40:2026-12-01", + "22:40:2025-01-05", + "22:40:2025-02-10", + "22:40:2025-03-02" + ] + }, + { + "case_id": "tiger_woods_1975_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 27, + "tied_for_first": false, + "gap": 0, + "percents": [ + 15, + 15, + 15 + ] + }, + "final": { + "met": false, + "width": 35, + "tied_for_first": false, + "gap": 0, + "percents": [ + 12, + 12, + 12 + ] + }, + "injected": [ + "22:50:2022-03-30", + "22:50:2022-06-29", + "22:50:2026-12-01", + "22:50:2025-03-31", + "22:50:2025-10-31", + "22:50:2024-01-30" + ] + }, + { + "case_id": "tiger_woods_1975_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 27, + "tied_for_first": false, + "gap": 0, + "percents": [ + 15, + 15, + 15 + ] + }, + "final": { + "met": false, + "width": 27, + "tied_for_first": false, + "gap": 0, + "percents": [ + 15, + 15, + 15 + ] + }, + "injected": [ + "23:06:2026-03-12", + "23:06:2026-10-17", + "23:06:2026-12-01", + "23:06:2025-03-02", + "23:06:2025-10-02", + "23:06:2024-01-26" + ] + }, + { + "case_id": "tiger_woods_1975_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 9, + 9, + 8 + ] + }, + "final": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 10, + 10, + 10 + ] + }, + "injected": [ + "22:50:2026-10-01", + "22:50:2021-04-29", + "22:50:2025-05-23", + "22:50:2025-11-01", + "22:50:2024-12-10", + "22:50:2022-03-01" + ] + }, + { + "case_id": "tiger_woods_1975_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 9, + 9, + 8 + ] + }, + "final": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 10, + 10, + 10 + ] + }, + "injected": [ + "23:40:2026-10-01", + "23:40:2026-12-29", + "23:40:2018-02-03", + "23:40:2025-11-01", + "23:40:2024-10-07", + "23:40:2022-03-01" + ] + }, + { + "case_id": "elizabeth_taylor_1932_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 13, + "tied_for_first": false, + "gap": 1, + "percents": [ + 27, + 26, + 26 + ] + }, + "final": { + "met": false, + "width": 13, + "tied_for_first": false, + "gap": 1, + "percents": [ + 27, + 26, + 25 + ] + }, + "injected": [ + "02:30:2012-01-08", + "02:30:2012-02-20", + "02:30:2012-04-05", + "02:30:2012-05-16", + "02:30:2012-09-02", + "02:30:2012-11-07" + ] + }, + { + "case_id": "elizabeth_taylor_1932_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 13, + "tied_for_first": false, + "gap": 1, + "percents": [ + 27, + 26, + 26 + ] + }, + "final": { + "met": false, + "width": 13, + "tied_for_first": false, + "gap": 0, + "percents": [ + 26, + 26, + 26 + ] + }, + "injected": [ + "02:40:2011-11-25", + "02:40:2012-02-22", + "02:40:2012-04-02", + "02:40:2012-05-20", + "02:40:2012-09-09", + "02:40:2012-09-24" + ] + }, + { + "case_id": "elizabeth_taylor_1932_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": 0, + "windows": 0, + "met": true, + "after_six": { + "met": true, + "width": 7, + "tied_for_first": false, + "gap": 10, + "percents": [ + 55, + 45 + ] + } + }, + { + "case_id": "elizabeth_taylor_1932_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": 0, + "windows": 0, + "met": true, + "after_six": { + "met": true, + "width": 7, + "tied_for_first": false, + "gap": 10, + "percents": [ + 55, + 45 + ] + } + }, + { + "case_id": "elizabeth_taylor_1932_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 0, + "percents": [ + 16, + 16, + 15 + ] + }, + "final": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 1, + "percents": [ + 16, + 15, + 15 + ] + }, + "injected": [ + "02:30:2012-10-01", + "02:30:2012-11-01", + "02:30:2012-12-01", + "02:30:2003-08-08", + "02:30:2004-01-10", + "02:30:2004-05-26" + ] + }, + { + "case_id": "elizabeth_taylor_1932_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 0, + "percents": [ + 16, + 16, + 15 + ] + }, + "final": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 1, + "percents": [ + 16, + 15, + 15 + ] + }, + "injected": [ + "02:58:2012-10-01", + "02:58:2012-11-01", + "02:58:2012-12-01", + "02:58:2003-09-10", + "02:58:2004-01-26", + "02:58:2004-07-08" + ] + }, + { + "case_id": "elizabeth_montgomery_1933_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 7, + "tied_for_first": false, + "gap": 0, + "percents": [ + 34, + 34, + 33 + ] + }, + "final": { + "met": false, + "width": 7, + "tied_for_first": false, + "gap": 0, + "percents": [ + 34, + 34, + 33 + ] + }, + "injected": [ + "16:38:2013-10-20", + "16:38:2012-01-23", + "16:38:2012-03-18", + "16:38:2012-05-06", + "16:38:2012-05-26", + "16:38:2012-07-24" + ] + }, + { + "case_id": "elizabeth_montgomery_1933_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 7, + "tied_for_first": false, + "gap": 0, + "percents": [ + 34, + 34, + 33 + ] + }, + "final": { + "met": false, + "width": 7, + "tied_for_first": false, + "gap": 1, + "percents": [ + 34, + 33, + 33 + ] + }, + "injected": [ + "16:42:2013-09-28", + "16:42:2012-01-02", + "16:42:2012-02-25", + "16:42:2012-04-14", + "16:42:2012-05-05", + "16:42:2012-07-02" + ] + }, + { + "case_id": "elizabeth_montgomery_1933_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 0, + "percents": [ + 22, + 22, + 19 + ] + }, + "final": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 17 + ] + }, + "injected": [ + "16:38:2013-03-25", + "16:38:2013-04-01", + "16:38:2013-09-01", + "16:38:2011-12-08", + "16:38:2012-05-15", + "16:38:2012-10-01" + ] + }, + { + "case_id": "elizabeth_montgomery_1933_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 0, + "percents": [ + 22, + 22, + 19 + ] + }, + "final": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 17 + ] + }, + "injected": [ + "16:10:2007-04-14", + "16:10:2013-04-01", + "16:10:2013-09-01", + "16:10:2012-03-16", + "16:10:2007-01-27", + "16:10:2012-10-01" + ] + }, + { + "case_id": "elizabeth_montgomery_1933_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 55, + "tied_for_first": false, + "gap": 1, + "percents": [ + 12, + 11, + 10 + ] + }, + "final": { + "met": false, + "width": 13, + "tied_for_first": false, + "gap": 0, + "percents": [ + 35, + 35, + 30 + ] + }, + "injected": [ + "16:38:2013-08-01", + "16:38:2007-05-23", + "16:38:2000-08-20", + "16:38:1999-08-07", + "16:38:1986-04-05", + "16:38:1986-04-05" + ] + }, + { + "case_id": "elizabeth_montgomery_1933_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 55, + "tied_for_first": false, + "gap": 1, + "percents": [ + 12, + 11, + 10 + ] + }, + "final": { + "met": false, + "width": 47, + "tied_for_first": false, + "gap": 1, + "percents": [ + 28, + 27, + 23 + ] + }, + "injected": [ + "15:54:2013-08-01", + "15:54:2007-04-14", + "15:54:2000-03-31", + "15:54:1999-10-23", + "15:54:1986-04-16", + "15:54:1986-04-16" + ] + }, + { + "case_id": "pablo_picasso_1881_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 17, + 17, + 17 + ] + }, + "final": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 17, + 17, + 17 + ] + }, + "injected": [] + }, + { + "case_id": "pablo_picasso_1881_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 17, + 17, + 17 + ] + }, + "final": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 17, + 17, + 17 + ] + }, + "injected": [] + }, + { + "case_id": "pablo_picasso_1881_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 7, + 7, + 7 + ] + }, + "final": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 7, + 7, + 7 + ] + }, + "injected": [] + }, + { + "case_id": "pablo_picasso_1881_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 7, + 7, + 7 + ] + }, + "final": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 7, + 7, + 7 + ] + }, + "injected": [] + }, + { + "case_id": "pablo_picasso_1881_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 1439, + "tied_for_first": false, + "gap": 0, + "percents": [ + 4, + 4, + 4 + ] + }, + "final": { + "met": false, + "width": 1439, + "tied_for_first": false, + "gap": 0, + "percents": [ + 4, + 4, + 4 + ] + }, + "injected": [] + }, + { + "case_id": "pablo_picasso_1881_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 1439, + "tied_for_first": false, + "gap": 0, + "percents": [ + 4, + 4, + 4 + ] + }, + "final": { + "met": false, + "width": 1439, + "tied_for_first": false, + "gap": 0, + "percents": [ + 4, + 4, + 4 + ] + }, + "injected": [] + }, + { + "case_id": "sigmund_freud_1856_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 18 + ] + }, + "final": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 18 + ] + }, + "injected": [] + }, + { + "case_id": "sigmund_freud_1856_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 18 + ] + }, + "final": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 18 + ] + }, + "injected": [] + }, + { + "case_id": "sigmund_freud_1856_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 8, + 8, + 8 + ] + }, + "final": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 8, + 8, + 8 + ] + }, + "injected": [] + }, + { + "case_id": "sigmund_freud_1856_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 8, + 8, + 8 + ] + }, + "final": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 8, + 8, + 8 + ] + }, + "injected": [] + }, + { + "case_id": "sigmund_freud_1856_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 121, + "tied_for_first": false, + "gap": 1, + "percents": [ + 5, + 4, + 4 + ] + }, + "final": { + "met": false, + "width": 121, + "tied_for_first": false, + "gap": 1, + "percents": [ + 5, + 4, + 4 + ] + }, + "injected": [] + }, + { + "case_id": "sigmund_freud_1856_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 0, + "met": false, + "after_six": { + "met": false, + "width": 121, + "tied_for_first": false, + "gap": 1, + "percents": [ + 5, + 4, + 4 + ] + }, + "final": { + "met": false, + "width": 121, + "tied_for_first": false, + "gap": 1, + "percents": [ + 5, + 4, + 4 + ] + }, + "injected": [] + }, + { + "case_id": "salma_hayek_1966_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": 0, + "windows": 0, + "met": true, + "after_six": { + "met": true, + "width": 9, + "tied_for_first": false, + "gap": 7, + "percents": [ + 40, + 33, + 27 + ] + } + }, + { + "case_id": "salma_hayek_1966_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": 0, + "windows": 0, + "met": true, + "after_six": { + "met": true, + "width": 9, + "tied_for_first": false, + "gap": 7, + "percents": [ + 40, + 33, + 27 + ] + } + }, + { + "case_id": "salma_hayek_1966_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 37, + "tied_for_first": false, + "gap": 0, + "percents": [ + 11, + 11, + 11 + ] + }, + "final": { + "met": false, + "width": 37, + "tied_for_first": false, + "gap": 0, + "percents": [ + 12, + 12, + 12 + ] + }, + "injected": [ + "06:40:2026-03-12", + "06:40:2026-05-10", + "06:40:2026-06-09", + "06:40:2026-09-11", + "06:40:2026-12-01", + "06:40:2025-10-12" + ] + }, + { + "case_id": "salma_hayek_1966_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 37, + "tied_for_first": false, + "gap": 0, + "percents": [ + 11, + 11, + 11 + ] + }, + "final": { + "met": false, + "width": 25, + "tied_for_first": false, + "gap": 0, + "percents": [ + 15, + 15, + 14 + ] + }, + "injected": [ + "06:22:2026-03-07", + "06:22:2026-07-15", + "06:22:2026-08-13", + "06:22:2026-10-24", + "06:22:2026-12-01", + "06:22:2025-10-12" + ] + }, + { + "case_id": "salma_hayek_1966_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 95, + "tied_for_first": false, + "gap": 0, + "percents": [ + 14, + 14, + 12 + ] + }, + "final": { + "met": false, + "width": 95, + "tied_for_first": false, + "gap": 0, + "percents": [ + 13, + 13, + 11 + ] + }, + "injected": [ + "06:40:2026-03-25", + "06:40:2026-07-11", + "06:40:2026-03-25", + "06:40:2023-03-13", + "06:40:1998-12-03", + "06:40:2023-03-13" + ] + }, + { + "case_id": "salma_hayek_1966_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 95, + "tied_for_first": false, + "gap": 0, + "percents": [ + 14, + 14, + 12 + ] + }, + "final": { + "met": false, + "width": 95, + "tied_for_first": false, + "gap": 0, + "percents": [ + 13, + 13, + 11 + ] + }, + "injected": [ + "07:36:2025-11-13", + "07:36:2026-05-26", + "07:36:2025-11-13", + "07:36:2023-05-15", + "07:36:2023-01-18", + "07:36:2023-06-13" + ] + }, + { + "case_id": "steve_reich_1936_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 17, + "tied_for_first": false, + "gap": 4, + "percents": [ + 25, + 21, + 20 + ] + }, + "final": { + "met": false, + "width": 17, + "tied_for_first": false, + "gap": 3, + "percents": [ + 24, + 21, + 20 + ] + }, + "injected": [ + "18:21:2016-11-01", + "18:21:2016-02-29", + "18:21:2016-03-23", + "18:21:2016-05-03", + "18:21:2016-05-01", + "18:21:2016-11-18" + ] + }, + { + "case_id": "steve_reich_1936_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 17, + "tied_for_first": false, + "gap": 4, + "percents": [ + 25, + 21, + 20 + ] + }, + "final": { + "met": false, + "width": 17, + "tied_for_first": false, + "gap": 3, + "percents": [ + 24, + 21, + 20 + ] + }, + "injected": [ + "18:31:2016-11-01", + "18:31:2012-03-13", + "18:31:2016-03-06", + "18:31:2016-05-04", + "18:31:2016-05-01", + "18:31:2016-11-19" + ] + }, + { + "case_id": "steve_reich_1936_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 35, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 17 + ] + }, + "final": { + "met": false, + "width": 5, + "tied_for_first": false, + "gap": 2, + "percents": [ + 51, + 49 + ] + }, + "injected": [ + "18:21:2016-01-21", + "18:21:2016-02-29", + "18:21:2016-03-23", + "18:21:2016-05-22", + "18:21:2016-05-22", + "18:21:2016-10-02" + ] + }, + { + "case_id": "steve_reich_1936_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 35, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 17 + ] + }, + "final": { + "met": false, + "width": 35, + "tied_for_first": false, + "gap": 0, + "percents": [ + 15, + 15, + 15 + ] + }, + "injected": [ + "17:51:2015-10-17", + "17:51:2007-07-03", + "17:51:2016-05-12", + "17:51:2013-10-06", + "17:51:2013-10-06", + "17:51:2012-02-17" + ] + }, + { + "case_id": "steve_reich_1936_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 29, + "tied_for_first": false, + "gap": 0, + "percents": [ + 11, + 11, + 11 + ] + }, + "final": { + "met": false, + "width": 29, + "tied_for_first": false, + "gap": 0, + "percents": [ + 11, + 11, + 11 + ] + }, + "injected": [ + "18:21:2016-08-01", + "18:21:2015-07-01", + "18:21:2014-10-01", + "18:21:2016-02-29", + "18:21:2016-04-19", + "18:21:2016-08-17" + ] + }, + { + "case_id": "steve_reich_1936_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 29, + "tied_for_first": false, + "gap": 0, + "percents": [ + 11, + 11, + 11 + ] + }, + "final": { + "met": false, + "width": 29, + "tied_for_first": false, + "gap": 1, + "percents": [ + 12, + 11, + 11 + ] + }, + "injected": [ + "18:05:2016-08-01", + "18:05:2015-07-01", + "18:05:2014-10-01", + "18:05:2011-02-12", + "18:05:2012-08-13", + "18:05:2016-08-02" + ] + }, + { + "case_id": "albert_brooks_1947_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 16, + 16, + 15 + ] + }, + "final": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 1, + "percents": [ + 15, + 14, + 14 + ] + }, + "injected": [ + "03:00:2026-01-01", + "03:00:2026-02-14", + "03:00:2026-03-04", + "03:00:2026-04-25", + "03:00:2026-06-08", + "03:00:2026-07-08" + ] + }, + { + "case_id": "albert_brooks_1947_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 16, + 16, + 15 + ] + }, + "final": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 1, + "percents": [ + 15, + 14, + 14 + ] + }, + "injected": [ + "03:10:2025-12-16", + "03:10:2026-01-29", + "03:10:2026-02-16", + "03:10:2026-04-10", + "03:10:2026-05-23", + "03:10:2026-06-22" + ] + }, + { + "case_id": "albert_brooks_1947_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 4, + "percents": [ + 24, + 20, + 20 + ] + }, + "final": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 3, + "percents": [ + 20, + 17, + 17 + ] + }, + "injected": [ + "03:00:2026-01-01", + "03:00:2026-02-14", + "03:00:2026-04-25", + "03:00:2026-06-08", + "03:00:2026-07-08", + "03:00:2025-11-13" + ] + }, + { + "case_id": "albert_brooks_1947_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 4, + "percents": [ + 24, + 20, + 20 + ] + }, + "final": { + "met": false, + "width": 33, + "tied_for_first": false, + "gap": 2, + "percents": [ + 17, + 15, + 15 + ] + }, + "injected": [ + "02:30:2025-12-31", + "02:30:2026-02-18", + "02:30:2026-04-21", + "02:30:2026-06-12", + "02:30:2026-07-25", + "02:30:2025-11-20" + ] + }, + { + "case_id": "albert_brooks_1947_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 107, + "tied_for_first": false, + "gap": 0, + "percents": [ + 9, + 9, + 8 + ] + }, + "final": { + "met": false, + "width": 107, + "tied_for_first": false, + "gap": 1, + "percents": [ + 9, + 8, + 8 + ] + }, + "injected": [ + "03:00:2026-02-14", + "03:00:2026-11-01", + "03:00:2025-01-01", + "03:00:2024-06-13", + "03:00:2025-03-01", + "03:00:2025-03-01" + ] + }, + { + "case_id": "albert_brooks_1947_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 107, + "tied_for_first": false, + "gap": 0, + "percents": [ + 9, + 9, + 8 + ] + }, + "final": { + "met": false, + "width": 107, + "tied_for_first": false, + "gap": 0, + "percents": [ + 8, + 8, + 8 + ] + }, + "injected": [ + "04:00:2026-01-20", + "04:00:2026-11-01", + "04:00:2025-01-01", + "04:00:2025-03-16", + "04:00:2025-03-01", + "04:00:2025-03-01" + ] + }, + { + "case_id": "paul_ryan_1970_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 28, + 28, + 25 + ] + }, + "final": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 1, + "percents": [ + 36, + 35, + 29 + ] + }, + "injected": [ + "02:37:2026-10-01", + "02:37:2026-05-04", + "02:37:2025-04-28", + "02:37:2025-06-04", + "02:37:2024-06-28", + "02:37:2024-09-04" + ] + }, + { + "case_id": "paul_ryan_1970_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 28, + 28, + 25 + ] + }, + "final": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 1, + "percents": [ + 37, + 36, + 28 + ] + }, + "injected": [ + "02:47:2026-10-01", + "02:47:2025-12-03", + "02:47:2025-08-07", + "02:47:2025-05-18", + "02:47:2025-05-15", + "02:47:2025-07-04" + ] + }, + { + "case_id": "paul_ryan_1970_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 53, + "tied_for_first": false, + "gap": 1, + "percents": [ + 11, + 10, + 10 + ] + }, + "final": { + "met": false, + "width": 49, + "tied_for_first": false, + "gap": 2, + "percents": [ + 15, + 13, + 13 + ] + }, + "injected": [ + "02:37:2026-04-22", + "02:37:2026-03-01", + "02:37:2026-06-01", + "02:37:2026-06-01", + "02:37:2026-10-01", + "02:37:2026-10-01" + ] + }, + { + "case_id": "paul_ryan_1970_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 53, + "tied_for_first": false, + "gap": 1, + "percents": [ + 11, + 10, + 10 + ] + }, + "final": { + "met": false, + "width": 49, + "tied_for_first": false, + "gap": 1, + "percents": [ + 14, + 13, + 13 + ] + }, + "injected": [ + "02:07:2026-01-16", + "02:07:2026-03-01", + "02:07:2026-06-01", + "02:07:2026-06-01", + "02:07:2026-10-01", + "02:07:2026-10-01" + ] + }, + { + "case_id": "paul_ryan_1970_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 53, + "tied_for_first": false, + "gap": 1, + "percents": [ + 10, + 9, + 9 + ] + }, + "final": { + "met": false, + "width": 47, + "tied_for_first": false, + "gap": 1, + "percents": [ + 12, + 11, + 11 + ] + }, + "injected": [ + "02:37:2026-06-05", + "02:37:2026-05-04", + "02:37:2026-10-01", + "02:37:2025-04-28", + "02:37:2025-06-04", + "02:37:2024-06-28" + ] + }, + { + "case_id": "paul_ryan_1970_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 53, + "tied_for_first": false, + "gap": 1, + "percents": [ + 10, + 9, + 9 + ] + }, + "final": { + "met": false, + "width": 47, + "tied_for_first": false, + "gap": 0, + "percents": [ + 13, + 13, + 12 + ] + }, + "injected": [ + "02:01:2026-06-21", + "02:01:2017-05-09", + "02:01:2026-10-01", + "02:01:2016-09-04", + "02:01:2025-08-03", + "02:01:2015-08-30" + ] + }, + { + "case_id": "tom_kennedy_1927_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 11, + "percents": [ + 41, + 30, + 29 + ] + }, + "final": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 8, + "percents": [ + 39, + 31, + 31 + ] + }, + "injected": [ + "17:25:2007-12-01", + "17:25:2005-05-01", + "17:25:2001-12-30", + "17:25:2002-11-07", + "17:25:2001-08-18", + "17:25:2001-12-06" + ] + }, + { + "case_id": "tom_kennedy_1927_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 11, + "percents": [ + 41, + 30, + 29 + ] + }, + "final": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 8, + "percents": [ + 39, + 31, + 30 + ] + }, + "injected": [ + "17:15:2007-12-01", + "17:15:2005-05-18", + "17:15:2002-01-17", + "17:15:2002-11-24", + "17:15:2001-09-05", + "17:15:2001-12-23" + ] + }, + { + "case_id": "tom_kennedy_1927_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 14, + "percents": [ + 57, + 43 + ] + }, + "final": { + "met": false, + "width": 13, + "tied_for_first": false, + "gap": 13, + "percents": [ + 42, + 29, + 29 + ] + }, + "injected": [ + "17:25:2007-12-01", + "17:25:2005-02-24", + "17:25:2005-02-24", + "17:25:2005-02-24", + "17:25:2005-05-01", + "17:25:2004-04-29" + ] + }, + { + "case_id": "tom_kennedy_1927_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 14, + "percents": [ + 57, + 43 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 12, + "percents": [ + 56, + 44 + ] + }, + "injected": [ + "17:19:2007-12-01", + "17:19:2006-04-30", + "17:19:2006-10-11", + "17:19:2006-04-30", + "17:19:2005-05-11", + "17:19:2005-07-02" + ] + }, + { + "case_id": "tom_kennedy_1927_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 57, + "tied_for_first": false, + "gap": 1, + "percents": [ + 12, + 11, + 11 + ] + }, + "final": { + "met": false, + "width": 85, + "tied_for_first": false, + "gap": 0, + "percents": [ + 10, + 10, + 9 + ] + }, + "injected": [ + "17:25:2007-09-01", + "17:25:2006-05-29", + "17:25:2006-02-01", + "17:25:2006-02-01", + "17:25:2005-05-01", + "17:25:2005-06-01" + ] + }, + { + "case_id": "tom_kennedy_1927_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 57, + "tied_for_first": false, + "gap": 1, + "percents": [ + 12, + 11, + 11 + ] + }, + "final": { + "met": false, + "width": 85, + "tied_for_first": false, + "gap": 0, + "percents": [ + 10, + 10, + 9 + ] + }, + "injected": [ + "16:35:2007-09-01", + "16:35:2006-01-18", + "16:35:2006-02-01", + "16:35:2006-02-01", + "16:35:2005-05-01", + "16:35:2005-06-01" + ] + }, + { + "case_id": "andrew_windsor_1960_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": 0, + "windows": 0, + "met": true, + "after_six": { + "met": true, + "width": 5, + "tied_for_first": false, + "gap": null, + "percents": [ + 100 + ] + } + }, + { + "case_id": "andrew_windsor_1960_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": 0, + "windows": 0, + "met": true, + "after_six": { + "met": true, + "width": 5, + "tied_for_first": false, + "gap": null, + "percents": [ + 100 + ] + } + }, + { + "case_id": "andrew_windsor_1960_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 27, + "tied_for_first": false, + "gap": 5, + "percents": [ + 38, + 33, + 29 + ] + }, + "final": { + "met": false, + "width": 35, + "tied_for_first": false, + "gap": 3, + "percents": [ + 24, + 21, + 19 + ] + }, + "injected": [ + "15:30:2026-05-25", + "15:30:2025-07-27", + "15:30:2024-09-28", + "15:30:2024-02-10", + "15:30:2022-10-23", + "15:30:2021-02-01" + ] + }, + { + "case_id": "andrew_windsor_1960_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 27, + "tied_for_first": false, + "gap": 5, + "percents": [ + 38, + 33, + 29 + ] + }, + "final": { + "met": false, + "width": 35, + "tied_for_first": false, + "gap": 3, + "percents": [ + 23, + 20, + 19 + ] + }, + "injected": [ + "15:52:2014-02-18", + "15:52:2013-11-09", + "15:52:2013-04-21", + "15:52:2023-10-21", + "15:52:2012-02-18", + "15:52:2020-10-12" + ] + }, + { + "case_id": "andrew_windsor_1960_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 0, + "percents": [ + 50, + 50 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 0, + "percents": [ + 50, + 50 + ] + }, + "injected": [ + "15:30:2026-01-19", + "15:30:2026-03-06", + "15:30:2026-05-17", + "15:30:2026-05-25", + "15:30:2026-07-03", + "15:30:2026-10-25" + ] + }, + { + "case_id": "andrew_windsor_1960_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 0, + "percents": [ + 50, + 50 + ] + }, + "final": { + "met": false, + "width": 83, + "tied_for_first": false, + "gap": 0, + "percents": [ + 22, + 22, + 19 + ] + }, + "injected": [ + "15:36:2025-12-20", + "15:36:2026-02-04", + "15:36:2026-04-17", + "15:36:2018-10-06", + "15:36:2026-06-03", + "15:36:2026-09-25" + ] + }, + { + "case_id": "sean_lennon_1975_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": 0, + "windows": 0, + "met": true, + "after_six": { + "met": true, + "width": 5, + "tied_for_first": false, + "gap": 20, + "percents": [ + 60, + 40 + ] + } + }, + { + "case_id": "sean_lennon_1975_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": 0, + "windows": 0, + "met": true, + "after_six": { + "met": true, + "width": 5, + "tied_for_first": false, + "gap": 20, + "percents": [ + 60, + 40 + ] + } + }, + { + "case_id": "sean_lennon_1975_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 19, + "tied_for_first": false, + "gap": 8, + "percents": [ + 33, + 25, + 22 + ] + }, + "final": { + "met": false, + "width": 19, + "tied_for_first": false, + "gap": 9, + "percents": [ + 40, + 31, + 29 + ] + }, + "injected": [ + "02:00:2026-05-04", + "02:00:2026-07-08", + "02:00:2026-12-01", + "02:00:2025-03-04", + "02:00:2025-05-16", + "02:00:2024-03-09" + ] + }, + { + "case_id": "sean_lennon_1975_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 19, + "tied_for_first": false, + "gap": 8, + "percents": [ + 33, + 25, + 22 + ] + }, + "final": { + "met": false, + "width": 19, + "tied_for_first": false, + "gap": 7, + "percents": [ + 31, + 24, + 23 + ] + }, + "injected": [ + "01:44:2026-04-24", + "01:44:2026-08-07", + "01:44:2026-12-01", + "01:44:2025-02-12", + "01:44:2025-06-11", + "01:44:2024-02-27" + ] + }, + { + "case_id": "sean_lennon_1975_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 1, + "percents": [ + 23, + 22, + 20 + ] + }, + "final": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 1, + "percents": [ + 23, + 22, + 20 + ] + }, + "injected": [ + "02:00:2022-11-20", + "02:00:2026-07-01", + "02:00:2026-08-01", + "02:00:2026-10-01", + "02:00:2026-12-01", + "02:00:2025-02-01" + ] + }, + { + "case_id": "sean_lennon_1975_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 1, + "percents": [ + 23, + 22, + 20 + ] + }, + "final": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 1, + "percents": [ + 23, + 22, + 20 + ] + }, + "injected": [ + "01:38:2020-06-01", + "01:38:2026-07-01", + "01:38:2026-08-01", + "01:38:2026-10-01", + "01:38:2026-12-01", + "01:38:2025-02-01" + ] + }, + { + "case_id": "joseph_kennedy_iii_1980_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 2, + "percents": [ + 35, + 33, + 33 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 1, + "percents": [ + 34, + 33, + 33 + ] + }, + "injected": [ + "21:42:2026-01-02", + "21:42:2026-02-07", + "21:42:2026-03-04", + "21:42:2026-05-07", + "21:42:2026-07-02", + "21:42:2026-09-08" + ] + }, + { + "case_id": "joseph_kennedy_iii_1980_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 2, + "percents": [ + 35, + 33, + 33 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 1, + "percents": [ + 34, + 33, + 33 + ] + }, + "injected": [ + "21:32:2026-01-18", + "21:32:2026-02-23", + "21:32:2026-03-20", + "21:32:2026-05-23", + "21:32:2026-07-19", + "21:32:2026-09-24" + ] + }, + { + "case_id": "joseph_kennedy_iii_1980_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 1, + "percents": [ + 12, + 11, + 11 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 2, + "percents": [ + 36, + 34, + 30 + ] + }, + "injected": [ + "21:42:2025-12-12", + "21:42:2026-01-02", + "21:42:2026-02-07", + "21:42:2026-03-04", + "21:42:2026-05-07", + "21:42:2026-07-02" + ] + }, + { + "case_id": "joseph_kennedy_iii_1980_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 1, + "percents": [ + 12, + 11, + 11 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 2, + "percents": [ + 36, + 34, + 30 + ] + }, + "injected": [ + "21:12:2026-01-29", + "21:12:2026-02-20", + "21:12:2026-03-27", + "21:12:2026-04-21", + "21:12:2026-06-24", + "21:12:2026-08-20" + ] + }, + { + "case_id": "joseph_kennedy_iii_1980_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 75, + "tied_for_first": false, + "gap": 1, + "percents": [ + 7, + 6, + 6 + ] + }, + "final": { + "met": false, + "width": 43, + "tied_for_first": false, + "gap": 0, + "percents": [ + 22, + 22, + 19 + ] + }, + "injected": [ + "21:42:2026-09-01", + "21:42:2026-11-01", + "21:42:2026-12-01", + "21:42:2024-11-27", + "21:42:2025-01-20", + "21:42:2025-05-06" + ] + }, + { + "case_id": "joseph_kennedy_iii_1980_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 75, + "tied_for_first": false, + "gap": 1, + "percents": [ + 7, + 6, + 6 + ] + }, + "final": { + "met": false, + "width": 39, + "tied_for_first": false, + "gap": 1, + "percents": [ + 28, + 27, + 24 + ] + }, + "injected": [ + "20:52:2026-09-01", + "20:52:2026-11-01", + "20:52:2026-12-01", + "20:52:2024-12-08", + "20:52:2024-12-26", + "20:52:2025-04-11" + ] + }, + { + "case_id": "kurt_cobain_1967_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 6, + "percents": [ + 39, + 33, + 28 + ] + }, + "final": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 4, + "percents": [ + 37, + 33, + 30 + ] + }, + "injected": [ + "19:38:2025-02-17", + "19:38:2025-08-08", + "19:38:2024-05-29", + "19:38:2025-02-17", + "19:38:2023-04-08", + "19:38:2023-10-12" + ] + }, + { + "case_id": "kurt_cobain_1967_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 6, + "percents": [ + 39, + 33, + 28 + ] + }, + "final": { + "met": false, + "width": 15, + "tied_for_first": false, + "gap": 3, + "percents": [ + 36, + 33, + 30 + ] + }, + "injected": [ + "19:46:2025-01-14", + "19:46:2025-07-06", + "19:46:2024-04-26", + "19:46:2025-01-14", + "19:46:2023-03-05", + "19:46:2023-09-09" + ] + }, + { + "case_id": "kurt_cobain_1967_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 39, + "tied_for_first": false, + "gap": 1, + "percents": [ + 14, + 13, + 13 + ] + }, + "final": { + "met": false, + "width": 35, + "tied_for_first": false, + "gap": 1, + "percents": [ + 16, + 15, + 14 + ] + }, + "injected": [ + "19:38:2026-02-07", + "19:38:2026-07-19", + "19:38:2025-02-17", + "19:38:2025-05-29", + "19:38:2025-08-08", + "19:38:2026-02-07" + ] + }, + { + "case_id": "kurt_cobain_1967_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 39, + "tied_for_first": false, + "gap": 1, + "percents": [ + 14, + 13, + 13 + ] + }, + "final": { + "met": false, + "width": 35, + "tied_for_first": false, + "gap": 1, + "percents": [ + 16, + 15, + 14 + ] + }, + "injected": [ + "19:18:2025-10-31", + "19:18:2026-05-01", + "19:18:2025-03-11", + "19:18:2025-05-11", + "19:18:2025-08-21", + "19:18:2025-10-31" + ] + }, + { + "case_id": "kurt_cobain_1967_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 121, + "tied_for_first": false, + "gap": 0, + "percents": [ + 6, + 6, + 6 + ] + }, + "final": { + "met": false, + "width": 115, + "tied_for_first": false, + "gap": 0, + "percents": [ + 6, + 6, + 5 + ] + }, + "injected": [ + "19:38:2023-12-20", + "19:38:2023-06-01", + "19:38:2021-10-31", + "19:38:2022-02-10", + "19:38:2021-04-24", + "19:38:2021-10-31" + ] + }, + { + "case_id": "kurt_cobain_1967_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 121, + "tied_for_first": false, + "gap": 0, + "percents": [ + 6, + 6, + 6 + ] + }, + "final": { + "met": false, + "width": 115, + "tied_for_first": false, + "gap": 0, + "percents": [ + 6, + 6, + 5 + ] + }, + "injected": [ + "18:38:2024-06-18", + "18:38:2023-10-15", + "18:38:2022-03-09", + "18:38:2022-07-08", + "18:38:2021-09-07", + "18:38:2022-03-09" + ] + }, + { + "case_id": "john_robbins_1947_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 8, + "percents": [ + 54, + 46 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 6, + "percents": [ + 53, + 47 + ] + }, + "injected": [ + "02:56:2026-08-27", + "02:56:2026-11-01", + "02:56:2024-12-06", + "02:56:2025-05-01", + "02:56:2025-08-11", + "02:56:2024-01-24" + ] + }, + { + "case_id": "john_robbins_1947_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 8, + "percents": [ + 54, + 46 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 6, + "percents": [ + 53, + 47 + ] + }, + "injected": [ + "02:46:2026-08-17", + "02:46:2026-11-01", + "02:46:2024-12-16", + "02:46:2025-04-29", + "02:46:2025-09-19", + "02:46:2024-02-04" + ] + }, + { + "case_id": "john_robbins_1947_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 0, + "percents": [ + 12, + 12, + 12 + ] + }, + "final": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 0, + "percents": [ + 12, + 12, + 12 + ] + }, + "injected": [ + "02:56:2025-08-11", + "02:56:2024-04-12", + "02:56:2024-06-15", + "02:56:2024-11-08", + "02:56:2022-10-12", + "02:56:2022-03-13" + ] + }, + { + "case_id": "john_robbins_1947_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 0, + "percents": [ + 12, + 12, + 12 + ] + }, + "final": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 0, + "percents": [ + 12, + 12, + 11 + ] + }, + "injected": [ + "02:26:2025-12-04", + "02:26:2024-08-05", + "02:26:2024-08-05", + "02:26:2024-12-24", + "02:26:2023-02-04", + "02:26:2022-07-06" + ] + }, + { + "case_id": "john_robbins_1947_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 121, + "tied_for_first": false, + "gap": 0, + "percents": [ + 7, + 7, + 7 + ] + }, + "final": { + "met": false, + "width": 55, + "tied_for_first": false, + "gap": 0, + "percents": [ + 8, + 8, + 8 + ] + }, + "injected": [ + "02:56:2026-03-07", + "02:56:2025-05-30", + "02:56:2025-08-11", + "02:56:2024-04-12", + "02:56:2024-08-31", + "02:56:2023-03-16" + ] + }, + { + "case_id": "john_robbins_1947_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 121, + "tied_for_first": false, + "gap": 0, + "percents": [ + 7, + 7, + 7 + ] + }, + "final": { + "met": false, + "width": 55, + "tied_for_first": false, + "gap": 1, + "percents": [ + 13, + 12, + 11 + ] + }, + "injected": [ + "03:56:2026-05-09", + "03:56:2025-07-19", + "03:56:2025-10-24", + "03:56:2024-04-20", + "03:56:2024-09-13", + "03:56:2023-06-08" + ] + }, + { + "case_id": "chad_everett_1937_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 0, + "percents": [ + 27, + 27, + 23 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 1, + "percents": [ + 28, + 27, + 23 + ] + }, + "injected": [ + "22:20:2017-01-27", + "22:20:2017-04-14", + "22:20:2017-06-07", + "22:20:2017-10-25", + "22:20:2016-01-05", + "22:20:2016-01-05" + ] + }, + { + "case_id": "chad_everett_1937_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 0, + "percents": [ + 27, + 27, + 23 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 0, + "percents": [ + 27, + 27, + 23 + ] + }, + "injected": [ + "22:10:2017-01-31", + "22:10:2017-03-18", + "22:10:2017-06-04", + "22:10:2017-07-28", + "22:10:2015-10-08", + "22:10:2016-02-25" + ] + }, + { + "case_id": "chad_everett_1937_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 1, + "percents": [ + 26, + 25, + 25 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 1, + "percents": [ + 26, + 25, + 25 + ] + }, + "injected": [ + "22:20:2017-01-27", + "22:20:2017-04-14", + "22:20:2017-06-07", + "22:20:2017-10-25", + "22:20:2016-01-05", + "22:20:2016-01-05" + ] + }, + { + "case_id": "chad_everett_1937_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 1, + "percents": [ + 26, + 25, + 25 + ] + }, + "final": { + "met": false, + "width": 11, + "tied_for_first": false, + "gap": 0, + "percents": [ + 25, + 25, + 25 + ] + }, + "injected": [ + "22:10:2017-01-31", + "22:10:2017-03-18", + "22:10:2017-06-04", + "22:10:2017-07-28", + "22:10:2015-10-08", + "22:10:2016-02-25" + ] + }, + { + "case_id": "chad_everett_1937_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 103, + "tied_for_first": false, + "gap": 0, + "percents": [ + 6, + 6, + 6 + ] + }, + "final": { + "met": false, + "width": 103, + "tied_for_first": false, + "gap": 0, + "percents": [ + 6, + 6, + 6 + ] + }, + "injected": [ + "22:20:2017-03-01", + "22:20:2017-11-01", + "22:20:2015-03-15", + "22:20:2014-08-28", + "22:20:2013-02-28", + "22:20:2013-02-28" + ] + }, + { + "case_id": "chad_everett_1937_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 103, + "tied_for_first": false, + "gap": 0, + "percents": [ + 6, + 6, + 6 + ] + }, + "final": { + "met": false, + "width": 103, + "tied_for_first": false, + "gap": 0, + "percents": [ + 7, + 7, + 7 + ] + }, + "injected": [ + "21:20:2017-03-01", + "21:20:2017-11-01", + "21:20:2015-06-29", + "21:20:2014-06-13", + "21:20:2013-04-18", + "21:20:2013-06-30" + ] + }, + { + "case_id": "bernd_eichinger_1949_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": true, + "gap": 0, + "percents": [ + 19, + 19, + 18 + ] + }, + "final": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 18 + ] + }, + "injected": [ + "13:15:2024-09-16", + "13:15:2025-04-10", + "13:15:2025-10-14", + "13:15:2024-09-23", + "13:15:2025-04-10", + "13:15:2024-03-13" + ] + }, + { + "case_id": "bernd_eichinger_1949_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 21, + "tied_for_first": true, + "gap": 0, + "percents": [ + 19, + 19, + 18 + ] + }, + "final": { + "met": false, + "width": 21, + "tied_for_first": false, + "gap": 0, + "percents": [ + 18, + 18, + 18 + ] + }, + "injected": [ + "13:05:2026-04-24", + "13:05:2026-06-10", + "13:05:2026-11-21", + "13:05:2024-10-10", + "13:05:2026-06-10", + "13:05:2025-09-25" + ] + }, + { + "case_id": "bernd_eichinger_1949_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 31, + "tied_for_first": true, + "gap": 0, + "percents": [ + 16, + 16, + 15 + ] + }, + "final": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 0, + "percents": [ + 16, + 16, + 15 + ] + }, + "injected": [ + "13:15:2026-02-04", + "13:15:2026-04-01", + "13:15:2026-12-01", + "13:15:2025-11-21", + "13:15:2026-04-01", + "13:15:2025-10-14" + ] + }, + { + "case_id": "bernd_eichinger_1949_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 31, + "tied_for_first": true, + "gap": 0, + "percents": [ + 16, + 16, + 15 + ] + }, + "final": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 0, + "percents": [ + 16, + 16, + 15 + ] + }, + "injected": [ + "13:45:2026-08-01", + "13:45:2026-09-26", + "13:45:2026-12-01", + "13:45:2025-02-13", + "13:45:2025-05-08", + "13:45:2024-04-10" + ] + }, + { + "case_id": "bernd_eichinger_1949_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 51, + "tied_for_first": true, + "gap": 0, + "percents": [ + 14, + 14, + 14 + ] + }, + "final": { + "met": false, + "width": 51, + "tied_for_first": false, + "gap": 0, + "percents": [ + 14, + 14, + 14 + ] + }, + "injected": [ + "13:15:2026-02-01", + "13:15:2026-04-01", + "13:15:2026-12-01", + "13:15:2025-02-01", + "13:15:2026-04-01", + "13:15:2025-10-01" + ] + }, + { + "case_id": "bernd_eichinger_1949_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 51, + "tied_for_first": true, + "gap": 0, + "percents": [ + 14, + 14, + 14 + ] + }, + "final": { + "met": false, + "width": 51, + "tied_for_first": false, + "gap": 0, + "percents": [ + 14, + 14, + 14 + ] + }, + "injected": [ + "14:05:2026-02-01", + "14:05:2026-04-01", + "14:05:2026-12-01", + "14:05:2025-02-01", + "14:05:2020-04-10", + "14:05:2025-10-01" + ] + }, + { + "case_id": "iwao_takamoto_1925_aa_v4_holdout", + "radius": 10, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 5, + "tied_for_first": false, + "gap": 0, + "percents": [ + 50, + 50 + ] + }, + "final": { + "met": false, + "width": 5, + "tied_for_first": false, + "gap": 0, + "percents": [ + 50, + 50 + ] + }, + "injected": [ + "20:00:2005-01-07", + "20:00:2005-02-06", + "20:00:2005-03-10", + "20:00:2005-04-07", + "20:00:2000-11-30", + "20:00:2005-05-11" + ] + }, + { + "case_id": "iwao_takamoto_1925_aa_v4_holdout", + "radius": 10, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 5, + "tied_for_first": false, + "gap": 0, + "percents": [ + 50, + 50 + ] + }, + "final": { + "met": false, + "width": 5, + "tied_for_first": false, + "gap": 0, + "percents": [ + 50, + 50 + ] + }, + "injected": [ + "20:04:2004-12-21", + "20:04:2005-02-21", + "20:04:2005-03-21", + "20:04:2005-04-24", + "20:04:2005-04-16", + "20:04:2005-05-24" + ] + }, + { + "case_id": "iwao_takamoto_1925_aa_v4_holdout", + "radius": 30, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 59, + "tied_for_first": false, + "gap": 0, + "percents": [ + 11, + 11, + 9 + ] + }, + "final": { + "met": false, + "width": 31, + "tied_for_first": false, + "gap": 0, + "percents": [ + 22, + 22, + 19 + ] + }, + "injected": [ + "20:00:1999-01-18", + "20:00:2005-09-01", + "20:00:1999-01-18", + "20:00:1998-11-12", + "20:00:2004-09-18", + "20:00:2004-12-16" + ] + }, + { + "case_id": "iwao_takamoto_1925_aa_v4_holdout", + "radius": 30, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 59, + "tied_for_first": false, + "gap": 0, + "percents": [ + 11, + 11, + 9 + ] + }, + "final": { + "met": false, + "width": 61, + "tied_for_first": false, + "gap": 0, + "percents": [ + 9, + 9, + 8 + ] + }, + "injected": [ + "19:30:1997-11-11", + "19:30:2005-09-01", + "19:30:1997-11-11", + "19:30:1999-01-15", + "19:30:2005-01-07", + "19:30:2005-03-08" + ] + }, + { + "case_id": "iwao_takamoto_1925_aa_v4_holdout", + "radius": 60, + "direction": "truth", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 17, + "tied_for_first": false, + "gap": 0, + "percents": [ + 23, + 23, + 19 + ] + }, + "final": { + "met": false, + "width": 13, + "tied_for_first": false, + "gap": 1, + "percents": [ + 28, + 27, + 23 + ] + }, + "injected": [ + "20:00:2005-01-07", + "20:00:2005-02-06", + "20:00:2005-04-07", + "20:00:2005-05-11", + "20:00:2005-06-22", + "20:00:2005-07-28" + ] + }, + { + "case_id": "iwao_takamoto_1925_aa_v4_holdout", + "radius": 60, + "direction": "opposite", + "events_needed": null, + "windows": 6, + "met": false, + "after_six": { + "met": false, + "width": 17, + "tied_for_first": false, + "gap": 0, + "percents": [ + 23, + 23, + 19 + ] + }, + "final": { + "met": false, + "width": 13, + "tied_for_first": false, + "gap": 0, + "percents": [ + 27, + 27, + 23 + ] + }, + "injected": [ + "20:16:2004-11-17", + "20:16:2005-01-29", + "20:16:2005-04-03", + "20:16:2005-04-15", + "20:16:2005-06-01", + "20:16:2005-06-01" + ] + } + ], + "errors": [] +} diff --git a/docs/tasks/PROGRESS-rectification-precision-gate-guided-collect-fix-20260916.md b/docs/tasks/PROGRESS-rectification-precision-gate-guided-collect-fix-20260916.md new file mode 100644 index 00000000..25ae7159 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-precision-gate-guided-collect-fix-20260916.md @@ -0,0 +1,187 @@ +# 进度 · 出卡精度门槛 + 引导式补经历(验收修复单,2026-09-16) + +- 任务书:`docs/tasks/TASK-rectification-precision-gate-guided-collect-fix-20260916.md`(F1~F7 + 决策 D6) +- 基线:`origin/staging` @ **`a396bbe0`**(第二轮已 rebase;上一版基线 `302ff085`,代码基线 `cfb41daf`) +- 分支 / 工作树:`codex/rectification-precision-gate-guided-collect-fix-20260916` / `.worktrees/rectification-precision-gate-guided-collect-fix-20260916` +- BUG:**747~752**。开工时最大号是 743;实现期间另一会话把侧栏统一那一单合入 staging,占掉了 744~746,所以本单整体后移到 747 起。rebase 后 `docs/BUG_HISTORY.md` 里 743 → 744 → … → 752 连续。 +- 开工检索(AGENTS §5):BUG-733、654、656、740~743 已逐条读完;BUG-733 的防复发条本轮补了一句(见下)。 + +## 0. rebase 到 `a396bbe0` + +第二轮已 `git rebase origin/staging`(侧栏统一那 4 笔:`d9d34723`、`fb77c865`、`22edabbc`、`a396bbe0`)。 + +- **自动合并**:`frontend/DESIGN.md`、`frontend/src/app/page.tsx`。page.tsx 以对方的结构为准,只保留本单那一处 `rectificationDeclaration` 合并(`declaredTime` + `birthDate` 一个对象展开)。 +- **手工解冲突 3 个**,都在文档、都是「两侧各自追加」: + - `CHANGELOG.md`:两条都保留,本单那条在上(后写的在前)。 + - `docs/BUG_HISTORY.md`:以 staging 版本为底,重新插入本单对 BUG-733 的那一行补注,再把本单的 BUG-747~752 整块追加到 BUG-746 之后。结果 `## BUG-` 共 739 条,末尾 743 → 744 → … → 752 连续。 + - `docs/tasks/README.md`:侧栏单那一行取对方的(已验收状态),本单那一行取自己的;两行都在。 +- BUG 编号 **747~752 不变**(744~746 已被侧栏轮占用)。 + +## 0.1 结论先行(全部在 `a396bbe0` 上重测;基线也在 `a396bbe0` 上用一次性 worktree 重跑) + +| 项 | 基线 `a396bbe0` | 本分支 | +| --- | --- | --- | +| `tsc --noEmit` | 0 错 | **0 错** | +| `npm run lint` | 0 error / 115 warning | **0 error / 115 warning** | +| `npm test` | 3399 条 / **47 红** | **3405 条 / 31 红**;31 红与基线逐条同名,**0 条新红**,基线 47 红里的 16 条全部转绿 | +| `pytest tests/test_rectification_*.py tests/test_event_probes_guided_windows.py` | — | **193 passed** | +| `next build --webpack` | `○ /` | **`○ /`**(`/chart`、`/ephemeris` 也仍是 Static) | +| 首屏 gzip | 583,765 | **582,800**(−965 B,**−0.17 %**) | +| `frontend/src/app/page.tsx` | 1836 行 | **1835 行**(−1,未增长) | +| Skill | 10.0.27 | **10.0.28**,sha256 `f0bb8295…cffe` | + +## 1. F1 · 记忆化 golden(BUG-747) + +- `tests/golden/rectification_engine_memoization_v1.json` 只追加 `guided_collect_windows` 一个键。`git diff --stat` = **`1 file changed, 62 insertions(+)`**,`git diff` 里只有一段 `+ "guided_collect_windows": [ … ]`,插在 `discriminating_event_probes` 之后(与 live receipt 的键序一致),CRLF 行尾保持不变。**没有删除或修改任何既有行。** +- 未放宽 `_assert_tiered_equal`(键集合仍是严格相等),未调用 `write_golden()`。 +- 顺带修了一个同族隐患:`_golden_payload()` 现在把 `scripts.rectification.event_probes` 的 `date` 换成 `_FrozenDate`(`today() == FROZEN_TODAY = 2026-09-16`)。窗口年份来自 `date.today()`,不冻结的话这份 golden 会在跨年时自己变红。冻结后生成的值与本机实跑一致,所以 golden 内容没有因此改变。 +- `docs/BUG_HISTORY.md` 的 BUG-733 条目补了一行「2026-09-16 因 receipt 新增键重生成,diff 仅此一键」。 + +## 2. F2 + F3 · 断言处置清单(BUG-748 / BUG-751) + +先按 F3 改代码,再逐条看断言。**没有删任何测试,没有 skip 任何测试。** + +### 2.1 代码改动(F3 + D6) + +`core/rectification-decision.ts` + +``` +mayDeliverOnPrecision(input): + userStopped === true → true + precisionGateMet 与 guidedCollectExhausted 都缺省 → true(门槛不参与,helper 路径回到 317e9f18) + 否则 guidedCollectExhausted === true + && refreshExhausted !== false + && targetedCollectExhausted !== false (= 所有题源都问完) +``` + +**口径(D2 + D6,第二轮修正)**:门槛只在**还有题可问时**挡住出卡。题源全空就是 D2 说的「用户确实补不出来」,卡片、采用按钮、三句正文照现行规则给,不加「未达门槛」标注。 +写全是 `userStopped || allExhausted || (precisionGateMet && allExhausted)`,化简后就是 `userStopped || allExhausted`。 +**按 D2 + D6,10 分钟门槛不改变出卡时机,本轮实际生效的是题源变多(引导窗口题 + 跳过线重问 + 七条线全部轮到)、线问完才出。** +`precisionGateMet` 保留计算,并新挂到 `RectificationDecision.precisionGateMet` 与 `publicNextAction().precision_gate_met`(helper 路径不传 flag 时为 `null`),只上报、不决定时机。 + +撤回的三处短路(全部回到原判断): + +1. `coverageBlocks` 分支的 `const narrowingOpen = stillNeedNarrowing(input) && !mayDeliverOnPrecision(input)` +2. `!separation.sufficient` 分支的 `if (stillNeedNarrowing(input) && !mayDeliverOnPrecision(input))` +3. `if (input.datedMethodCollectOpen === true && !input.userStopped && !mayDeliverOnPrecision(input))` + +保留门槛的五个最终交付分支:`tied_first` 耗尽、`!sufficient` 的 exhausted / adopt_representative / offer、顶层 exhausted。 + +`v9/decision-from-dossier.ts` 的 `narrowingExhaustion`:`guidedCollectExhausted` 改为 `refreshDone && guidedCollectExhausted(...)`,即引导池空但刷新没试过不算问完。 + +新增三条单测(`rectification-precision-gate-20260916.test.ts`): + +| 单测 | 断言 | +| --- | --- | +| `empty guided pool with refresh untried does not deliver` | `refreshExhausted: false` + 池空 + 门槛达标 → `ask_fact_collection`、`canOfferRange=false`;`mayDeliverOnPrecision` 直接返回 false | +| `gate met but the targeted line is still open keeps asking` | `precisionGateMet: true` + `targetedCollectExhausted: false` → `ask_fact_collection` | +| `every line asked but the gate unmet still delivers under the existing rules` | 池空 + 门槛未达 → `canOfferRange=true`、`sessionOutcomeAllowsDelivery=true`,且 `precisionGateMet` 仍报 `false`(D2:题源空了就按现行规则出卡,不加标注) | +| `the precision gate is reported on a collect exit too` | 采集出口上 `decision.precisionGateMet === false`、`publicNextAction().precision_gate_met === false`;helper 路径(不传 flag)为 `null` | + +### 2.2 十六条基线红(任务书 §2 F2 表) + +| # | 文件 :: 用例 | 处置 | +| --- | --- | --- | +| 1 | `adopt-flow-fix-20260903` :: collect-phase nonterminal exit… | **改断言(三栏)**:`adopt_representative/offer_provisional_range/publicCanAdopt=true` → `collect_evidence/ask_fact_collection/false`;随之 `repaired.persisted` false→true、repair 警告 false→true、`store.focus` null→非空。D1(13 分钟、14:13:13 并列)+ D6,本用例回到 BUG-648 之前的采集出口,与题名一致 | +| 2 | `adopt-flow-fix-20260903` :: skipped opening collect other still yields education… | **改断言(三栏)**:`plan.next_followup = null` → 学业线 `collect:targeted:education`;下半段 `educationPlan.next_followup.domain` `horary` → `education`。D5 七条线全部轮到,题名要的「yields education」正是这一条 | +| 3 | `adopt-narration-20260904` :: fourteen-probe case decides offer_provisional_range… | **仅改代码即恢复绿**(F3 撤回短路后 helper 路径回到原语义),断言未动 | +| 4 | `adopt-narration-20260904` :: family collect declined vs extra distinguish declined… | **改断言(三栏)**:`/搬家|换城市|还能把剩下的候选分开/` → 两侧都匹配 `/现在还剩 .+ 里 \d+ 个候选/`。D5 之后迁居已覆盖,剩下的线是财务与健康;题名要的「两侧同一个 adopt 决策、都落库」不变 | +| 5 | `answer-choice` :: answering the last discriminator persists a year-locked family collect focus… | **改断言(三栏)**:`setFocus === undefined` → 写入 `collect:targeted:education`;末条「没有 focus 带 `p_asked_turn_id === TURN_ID`」改成「写入的 focus 只能是这道新问题」——`linkFocusAskedTurn` 把新问题挂到本轮刚追加的 assistant turn,而夹具让这个 id 恰好等于 `TURN_ID`,旧断言在这个夹具里已无法区分两者 | +| 6 | `choice-card` :: family coverage without a renderable discriminator still collects the next dated domain | **改断言(三栏)**:`plan.next_followup = null` → 迁居线 `collect:targeted:relocation`。题名要的「still collects the next dated domain」正是这一条 | +| 7 | `collect-direction-20260904` :: holdout remaining domain uses the server collect stem… | **改断言(三栏)**:`choice_frame === null` → `choice_kind === "existence"`;`USER_COLLECT_QUESTION.education` → `TARGETED_EXISTENCE_PROMPT.education`,另加一条「不得是反向核对口吻」的断言,保住题名的原意 | +| 8 | `collect-direction-20260904` :: four scoreable events stop dated collect… | **改断言(三栏)**:`intent != collect_method_evidence` → `== collect_method_evidence` 且 `collection_key` 必须是 `collect:(targeted|guided):`(走定向七条线,不是旧的领域轮盘) | +| 9 | `collect-direction-20260904` :: separated candidates stop leftover dated collect once the training gate is open | **改断言(三栏)**:`sessionOutcome != collect_evidence` → `== collect_evidence`、`nextAction = ask_fact_collection`、`userAction.id = ask_method_followup` | +| 10 | `convergence-budget` :: dossier and post-inference decisions share the half-uncertain stop rule | **改断言(三栏)**:`stopReason = "probe_pool_exhausted"` → `null`,并显式断言两条路径的 `stopReason` 与 `nextAction` 一致(题名要的「share the rule」) | +| 11 | `decide-next-action` :: separated candidates stay in collect while dated method collect is still open | **仅改代码即恢复绿**,断言未动 | +| 12 | `delivery-vs-collect-20260914` :: POST idle persist and GET decideFromDossier agree on a first-place tie | **改断言(三栏)**:`getDelivers = true` → `false`(D1 并列时门槛恒假);「POST 与 GET 同口径」那条不变 | +| 13 | `superseded-focus` :: duplicate_focus on the last collect ask does not emit delivery copy | **改断言(三栏)**:`p_target_domain != relationship` → 改断言写入的是 `collect:(targeted|guided):` 问题(夹具本身叫 `relationshipOpenDossier`,这条线是开着的);`!= finance` 与「不得播交付文案」保留 | +| 14 | `targeted-collect-cards-20260913` :: A resolves existence then asks the year; B/C close only that line | **改断言(三栏)**:题干 `结过婚或订过婚吗?` → `TARGETED_EXISTENCE_PROMPT.relationship`(T4b 问整个领域);`targetedCollectExhausted` 从「拒答 4 条即耗尽」改成「4 条 → false、七条 → true」 | +| 15 | `targeted-collect-cards-20260913` :: personality D9/D10 stay out of the scoring pool… | **改断言(三栏)**:`narrow_hint` 含 `不限领域` → 不得含 `不限领域`,改为匹配引导句 `现在还剩 … 里 N 个候选`(D3 删自由文本邀请) | +| 16 | `tied-first-fix-20260914` :: tied first place with no probe still collects while targeted questions remain | **仅改代码即恢复绿**,断言未动(这正是 F3 说的 BUG-654 规则回来了) | + +### 2.3 D5/D6 连锁打红的另外 25 条(任务书未列,本轮一并处置) + +撤回 `datedMethodCollectOpen` / `stillNeedNarrowing` 两处短路之后,cfb41daf 的 T4b(`remainingTargetedDomains` 不再按分盘层剔除,七个 `CollectKind` 全部轮到)就重新挡住了交付——这是 D5 + D6 的**预期**结果,但 cfb41daf 用短路把它盖住了,所以这 25 条在基线上是绿的。全部按「原值 / 新值 / 原因」三栏改: + +| 文件 | 条数 | 处置摘要 | +| --- | ---: | --- | +| `coverage-collect` | 4 | `offer_provisional_range` / `ready_to_adopt` → `ask_fact_collection` + `collect_evidence`;`canAdopt` 仍是引擎能力位,保持 true | +| `provisional-adopt` | 6 | 公开投影 `can_adopt` / `session_outcome` 翻到采集侧;`canRenderRectificationSelectionCards` true→false;`buildNextUserAction` `adopt_representative`→`ask_method_followup`;`selectionAllowed` true→false | +| `range-offer-deadend` | 2 | `ready_to_adopt`→`ask_fact_collection`、`decision.canAdopt` true→false(零拆分探针被 drop 的断言保留) | +| `eight-method` | 1 | `rectification-offer-candidates` 由「可走」改为 `assert.rejects(/offer_not_allowed/)` | +| `collect-direction-20260904` | 1 | 同上(offer-candidates 仍被挡) | +| `exhaustion-exit-20260906` | 1 | `adopt_representative`→`collect_evidence`、`stopReason`→null、旁白由三句交付改为引导句 | +| `occupation-coverage-exit` | 1 | `canOfferRange` true→false、`collect_evidence` / `ask_fact_collection` | +| `holdout-renderable` | 1 | `offer/ready` → `ask_fact_collection`,`validated_range` 断言保留 | +| `collect-stall` | 1 | 只改夹具:补齐 education / career / relationship 三条拒答(D5 之后七条线都要轮到),断言全部保持原样(`publicCanAdopt=true`、`persisted=false`、不写 focus) | +| `adopt-flow-fix-20260903` | 1 | `adopt_representative nonterminal exit…`:第二轮修正口径后无需改动,夹具与断言均恢复原样 | +| `answer-choice` | 1 | `last structured choice…`:`ready_to_adopt` / `can_adopt=true` → `ask_fact_collection` / false(这条的原因是 D5 的线没问完,不是门槛) | +| `decision-authority` | 1 | 公开字段深比较补 `precision_gate_met`(BUG-692 要求不得改成部分匹配,所以补字段而不是放宽比较) | +| `probe-pool-exhausted-20260911` | 1 | T3 第二轮修正后恢复原样;T4 只把末条旁白正则补上引导句(`persistNextInterviewIfIdle` 自己重算决策,写的是采集侧区间旁白) | +| `open-collect-invite-20260914` | 0 | 第二轮修正口径后恢复原样,无改动 | +| `surface-contract` | 1 | `declaredTime={rectificationDeclaredTime}` → `{...rectificationDeclaration}`(F7) | +| `ingest-p0` / `delivery-report-facts` / `tie-break-entry-20260913` / `v9-contracts` | 4 | Skill 版本 10.0.27 → 10.0.28 | + +另有 10 个文件只是 `RECTIFICATION_SKILL_VERSION` 常量断言随 bump 改数字(`collect-stall`、`confirmation-gate`、`delivery-report-facts`、`eight-method`、`exhaustion-exit`、`ingest-p0`、`occupation-coverage-exit`、`range-offer-deadend`、`replay-20260911`、`spoken-collect`、`v9-agent`、`v9-contracts`、`v9-entry-routing`、`window-cluster-cap`、`yearless-probe-downgrade`、`skill-registry`),每处都带一行三栏注释。 + +### 2.4 最终红清单 + +`npm test` fail=31,与基线 31 条**逐条同名**:无 Docker 的 database / deployment / staging 套件,以及 `[eval]` 路径别名那一组。 +测试总数 3399 → **3405**(+6,全部是新增;**没有删除、没有 skip**):精度门槛 5 条(引导池空但刷新未试不交付、门槛达标但定向线未问完继续问、题源全空即使门槛未达也照现行规则出卡、采集出口也上报门槛、helper 路径上报 null)+ F4 的 3 条 + F6 的 1 条,其中 2 条是把既有用例改写后新增的姊妹条。 + +## 3. F4 · 无领域轨道的窗口改成开放题(BUG-749) + +- `scripts/rectification/event_probes.py`:删掉 `fallback[unlayered_index % len(fallback)]` 轮询;`vim` / `nara` 轨道的窗口 `domain = GUIDED_ANY_DOMAIN = "any"`;`nara:d9` / `nara:d10` 保持 `relationship` / `career`,该领域被拒答时也退回 `"any"`。`declined_domains` 现在只用于剔除例子与 d9/d10 的固定领域。 +- 前端:`GUIDED_ANY_DOMAIN` / `GuidedWindowDomain` / `normalizeGuidedWindowDomain` / `guidedAnyExamples` / `firstOpenCollectKind`;`guidedWindowPrompt(window, openDomains)` 对 `any` 写「YYYY 年 M 到 M 月之间,有没有什么事,比如<口语 ≤3 个>?」,口语从 `KIND_ORAL` 表取、剔除 declined 与已覆盖,**没有按领域写 if**;全部领域都关掉时退化成「有没有什么事?」。`windowAlreadyAsked` 改成只比对(年, month_lo, month_hi),换个领域标签不重问。A 之后 `pendingGuidedYearWindow` 把 `any` 解析成第一个仍开放的领域,录入卡芯片默认落在它上面(`resolveEntryDomain` 另有防御)。 +- 单测:Python 7 条(真实引擎 20 分钟窗 golden 非空、无领域轨道 = `any`、d9/d10 保留固定领域、拒答领域退回 `any`、split 均衡排序、跨年拆分);前端 3 条(开放题干口语 ≤3、同一窗口只问一次、芯片默认第一开放领域)。 + +## 4. F5 · 离线回放改成注入真值方向(BUG-752) + +`scripts/research/guided_collect_holdout_replay.py` 新增 `window_boundary_dates()`:按 `guided_collect_windows` 自己的循环重算(轨道, index),拿回**每个候选**在该窗口上的边界日期。`truth` 组注入真值候选自己的那个月,`opposite` 组注入「剩余候选里离真值最远的那一个」的同一边界。三档半径都跑,`minute_rectification_holdout_v4.json` 20 例,**351.6 s,错误 0**,未用让步(没有只跑 ±10/±30)。 + +| 半径 | 方向 | 20 例达标 | 其中六题后本来就达标 | **靠引导件补上** | 达标件数中位 | 每例窗口数中位 | +| --- | --- | ---: | ---: | ---: | ---: | ---: | +| ±10 | truth | 3 | 3 | **0** | 0 | 6 | +| ±10 | opposite | 3 | 3 | **0** | 0 | 6 | +| ±30 | truth | 1 | 1 | **0** | 0 | 6 | +| ±30 | opposite | 1 | 1 | **0** | 0 | 6 | +| ±60 | truth | 0 | 0 | **0** | — | 6 | +| ±60 | opposite | 0 | 0 | **0** | — | 6 | + +原始表 `docs/research/guided_collect_holdout_2026_09_16.json`(含每例的 `injected`,形如 `19:24:2026-05-08`,可核对注入确实落在不同候选的边界上)。 + +口径说明与观察: + +- 上一轮「0/20」的写法**不成立**(注入的是窗口左端点),这一轮按真值边界注入之后**结论仍然一样**:没有一例是「六题后未达、补引导件后达标」。这次的数字可以作数。 +- truth 与 opposite 两组数字完全相同,说明在这个 holdout 上,把事件放在真值的边界还是放在最远候选的边界,对 D1 门槛没有可测差别。 +- 每例都有 6 个窗口(上限就是 6),所以不是「题不够」。 +- 窗口年份集中在 2025–2026:排序先按 split 均衡、再按靠近今天,`lo = 出生年 + 5`、`hi = min(今年, 出生年+80)`。真人补经历时这几年确实最容易记得,但对名人 holdout 来说这些年份多半没有可用事件。 +- **这不是合入门槛**,产品据此决定 10 分钟门槛是否要调,或者引导题源是否还要换一条路。 + +## 5. F6 · 录入卡提交文本(BUG-750) + +`formatEventDateEntryMessage` → 「YYYY 年 M 月(D 日),<领域标签>方面有一件事」,标签取 `RANGE_DELIVERY_DOMAIN_LABEL`。单测断言两种日期形态与「不含『或』」。**没有**改成 `set-focus` / `batch` 的结构化写入(任务书给的是「或」二选一),走的仍是现有带年月证据路径。 + +## 6. F7 · page.tsx 与 golden fixture + +- `page.tsx` **1838 → 1837**:`rectificationDeclaredTime` 常量改成 `rectificationDeclaration = { declaredTime, birthDate }`,JSX 里两行 prop 合成一行 `{...rectificationDeclaration}`。 + **偏离**:任务书写的是「把那一行挪进 `conversational-birth-time-rectification.tsx` 内部取档案」。做不到而且不划算——该组件没有档案来源:`RectificationCaseSnapshotPayload` 的 `case` 段只有 `status / accepted_time / confirmed_time / candidate_range / stage / birth_time_source`,**没有 birth_date**;要在组件里拿档案得新开一次 `/api/account` 读取或改服务端合同,为了省一行代码多一次网络请求并不合算。行数目标已达成,记在这里由验收方判断。 +- `tests/test_event_probes_guided_windows.py` 重写:新增 `_real_built()`,用 `build_candidate_static_context` 对公开 1990-01-01 北京测试盘的 12:00–12:20(**20 分钟窗**,5 个采样分钟)跑真实引擎,断言窗口非空并逐字段校验;`test_declined_domains_are_removed` 改成 `test_declined_domains_fall_back_to_open_windows`,用真实引擎结果做,**`skipTest` 全部删除**(文件里已无 `skipTest`)。手造 static contexts 只留在排序与跨年两条纯结构断言里。 + +## 7. 记录 + +- `docs/BUG_HISTORY.md`:BUG-747~752(本分支内连续;744~746 属侧栏统一单,见 §0),BUG-733 补一行。 +- `CHANGELOG.md`:2026-09-16 「问完所有线才出卡,时间窗不再硬贴一个领域」,写明 Skill 10.0.28。 +- `frontend/DESIGN.md`:`guided-collect` 行改写(开放窗口题干、一窗一问、录入卡提交文本),`candidates` 行写明门槛是必要条件而非充分条件。 +- `frontend/docs/VOICE.md`:新增两行(开放窗口题干、录入卡提交文本),已对照既有口径。 +- `docs/tasks/README.md`:本单状态板改「已实现,待验收」+ 分支名 + 进度记录名。 +- 真机清单沿用 `docs/testing/rectification-guided-collect-20260916.md`(六条),本轮没有新增浏览器级验收项。 +- 未写入任何真实出生资料;示例只用公开名人 holdout 与 1990-01-01 测试盘。 + +## 8. 偏离与未做 + +1. **F7 的 `birthDate` 没有挪进组件内部**(理由见 §6),改用 props 合并把 page.tsx 降回 1837 行。 +2. **(第一轮偏离,已修正)** 第一轮按修复单 F3 §2 的字面实现了「门槛未达即使线问完也只进 collect」,与主单 D2「引导题源也空了就按现行规则出卡」冲突,会造出「没有题也没有卡」的死角。产品第二轮拍板以 D2 为准:门槛只在还有题可问时挡出卡,题源全空照现行规则出卡。当前实现是修正后的版本,本记录各处数字也是修正后重测的。 +3. **F2 实际处置 41 条而非 16 条**:任务书的 16 条表是在 cfb41daf 上数的,那一版用 `!mayDeliverOnPrecision` 短路盖住了 D5 带来的「七条线全部轮到」,撤回短路后另有 25 条既有断言跟着变。全部按三栏改,清单见 §2.3。 +4. `run_quality_gate.py --profile quick` 的最后一级是 `npm test`,本机无 Docker,那 31 条与基线同名的失败照旧;前面各级(JSON、capabilities、fragments、BPHS、CORE_PYTEST_TARGETS)通过。 +5. 浏览器级验收(登录态 / Chrome)、staging 部署核对仍是环境缺口,留给 `docs/testing/rectification-guided-collect-20260916.md`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 19fbb9e7..82feec90 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -253,7 +253,7 @@ | `RECONCILE-20260916.md` | — | **对账清单(不是任务书)**:状态板与现实脱节(09-15~16 那 10 份早已合入却仍写「待领取/待验收」,本轮已修正),所以另外 7 份 09-10~14 的单**不能拿 README 当证据**——它们没有 `PROGRESS-*.md`,但引用的 BUG 号都是 `resolved`。要么被别的单顺带修了没留记录,要么压根没做。每行回一个「做了/没做」即可。另附三条我已查到确定没做的证据、两条状态未闭环的、以及 BLK-001 仍红(2026-09-16 在 `4f643aa0` 复跑确认) | 待产品负责人 / 执行方回填 | — | | `TASK-rectification-precision-gate-guided-collect-20260916.md` | `PROGRESS-rectification-precision-gate-guided-collect-20260916.md` | **出卡加精度门槛 + 引导式补经历**:真机 30 分钟窗给 5 件事 / 3 领域就出卡,区间 20 分钟、5 候选并列 26/26/20%,卡下只有自由文本邀请。产品拍板 D1 出卡门槛「宽度 ≤10 分钟且前两名差 >3 个百分点且不并列」;D2 用户说没有了仍按现行规则出卡(保留永远给结果);D3 门槛未达改为系统点名逐题问 + 类型芯片 + 年/月选择器录入,删自由文本邀请;D4 跳过的线换问法再问一次(拒绝不重问,改 BUG-687 口径);D5 时间点题答「没发生」不关领域、存在性题问整个领域、七条线一张表不许按领域写死。引擎新增 `guided_collect_windows`(边界不设 45 天闸门、含小运),不改打分与 lead。Skill 10.0.27。附 T6 两条核实:B/C/D 回执文案对调、性格题后范围回弹 8 分钟。BUG-740~743 | **已验收:未通过(带修复单)** | `cfb41daf`;tsc/lint/Static/gzip(+0.44%) 通过,D1~D5 单测通过;但 `npm test` 新增 16 红(校正既有断言未跟上)+ memoization golden 因 receipt 新键红(门禁级,staging 仍部署 317e9f18);引导窗口领域轮询分配、回放未按真值注入、录入卡走模型轮 → `TASK-rectification-precision-gate-guided-collect-fix-20260916.md` | | `TASK-sidebar-unify-20260916.md` | `PROGRESS-sidebar-unify-20260916.md` | **侧栏统一成一个组件 + 次级页共享外壳 + 跳转不整页刷新(纯前端)**:`/` 用 `AppSidebar`、四个次级页用另写的 `AppNavRail`,两套标记结构共用一份 CSS,会话行少 `.session-main` 一层(无内边距 / 44px 高 / 当前项标记、留 44px 空列)、页脚 56px 菜单 vs 44px 文字;首页进次级页是 `window.location.assign` 整页刷新,次级页各自在组件里挂 `SecondaryShell`、无共享 layout 无缓存,每次跳转重拉 `/api/sessions` + `/api/account`;折叠状态不持久。产品拍板 D1 只留一个 `AppSidebar`(操作回调可选 = 只读模式,D9「不带写操作」维持)、D2 路由组 `app/(secondary)/` layout 承载外壳、D3 改 ``、D4 模块级 60 s 缓存 + 首页写操作失效、D5 折叠状态存 cookie、D6 回首页慢的启动链另开一单。实测:接口 TTFB 0.63~0.90 s,回 `/` 启动链串行 ≥4 次往返 + 揭幕闸门 4 s。BUG-744~746 | 已验收(2026-09-16,Opus 子代理实现 `22edabbc`,门禁全绿、失败清单与基线逐条相同;折叠状态取 localStorage 触发让步 1;真机清单待走) | `codex/sidebar-unify-20260916` | -| `TASK-rectification-precision-gate-guided-collect-fix-20260916.md` | — | **验收修复单**:F1 `decision_receipt` 新增 `guided_collect_windows` 让 `test_rectification_engine_memoization` golden 红(在 CORE_PYTEST_TARGETS,staging 未部署 cfb41daf);F2 16 条既有校正断言红未按三栏改;F3 `mayDeliverOnPrecision` 缺省即放行、短路 BUG-654 且 `guidedCollectExhausted` 不含 refresh;F4 无领域轨道窗口轮询贴领域、一窗只问一领域;F5 离线回放注入 month_lo 非真值方向、0/20 不作数;F6 录入卡合成「或」列表句走模型轮;F7 page.tsx +1、手造 fixture。D6 产品已拍板:门槛只是必要条件,仍问完线再出。BUG 段 744 起 | 待领取 | 与主单同文件;开工基线 3391 / 47 | +| `TASK-rectification-precision-gate-guided-collect-fix-20260916.md` | `PROGRESS-rectification-precision-gate-guided-collect-fix-20260916.md` | **验收修复单**:F1 `decision_receipt` 新增 `guided_collect_windows` 让 `test_rectification_engine_memoization` golden 红(在 CORE_PYTEST_TARGETS,staging 未部署 cfb41daf);F2 16 条既有校正断言红未按三栏改;F3 `mayDeliverOnPrecision` 缺省即放行、短路 BUG-654 且 `guidedCollectExhausted` 不含 refresh;F4 无领域轨道窗口轮询贴领域、一窗只问一领域;F5 离线回放注入 month_lo 非真值方向、0/20 不作数;F6 录入卡合成「或」列表句走模型轮;F7 page.tsx +1、手造 fixture。D6 产品已拍板:门槛只是必要条件,仍问完线再出。BUG 段 744 起 | **已实现,待验收** | `codex/rectification-precision-gate-guided-collect-fix-20260916`;进度记录 `PROGRESS-rectification-precision-gate-guided-collect-fix-20260916.md`;BUG-747~752;Skill 10.0.28;已 rebase 到 `a396bbe0`;`npm test` 3405 / 31 红(基线 `a396bbe0` 实测 3399 / 47,红清单逐条同名、0 条新红),`pytest tests/test_rectification_*.py tests/test_event_probes_guided_windows.py` 193 passed;首屏 gzip 583,765 → 582,800(−0.17%),`page.tsx` 1836 → 1835 | ## 命名与归档 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 16f745e2..1efdc9f1 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -260,8 +260,8 @@ The birth-time rectification session is the consultation transcript plus a house | `question-gap`, delivered | no current question, or the current question is a dead choice card, and the session already delivered a range (`completed_with_range` / `provisional_range` / adopt outcomes, or `tied_first`); range card or range line plus an exit note. Never “没有拿到下一个问题。” | enabled | | `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled | | `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | enabled (typing queues), stop visible | -| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a caption under the title. The card appears only when the credible range is ≤ 10 minutes, the top two relative likelihoods differ by more than 3 points, and first place is not tied — or the reader said there is nothing more to add. No free-text invite. Optional “再答两道参考题微调排序” only when unused D9/D10 remain; if those were already asked and the top two are still within one point, a line “这两分钟按现有信息分不开,参考题已经用过.”; then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold. On a normal convergence, unused style questions are asked before this card. Exhausted and closed-ceiling exits still deliver the card if a style question cannot be rendered, once the precision gate or the “nothing more” stop is met. | enabled | -| `guided-collect` | a named yes/no card (“YYYY 年 M 到 M 月之间,有没有…”) then an entry card: seven type chips (default the asked domain) plus a month-granularity date picker (day optional, years from birth year to this year). Composer stays enabled. | enabled (typing still records), stop visible | +| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a caption under the title. The card appears once every collect line has been asked (refresh, guided windows, the skip retry, the seven targeted kinds) or the reader said there is nothing more to add. The precision gate (range ≤ 10 minutes, top-two gap > 3 points, no exact tie) is reported as `precision_gate_met` but never gates the card on its own: meeting it skips no remaining line, and failing it adds no marker to the card once there is nothing left to ask. No free-text invite. Optional “再答两道参考题微调排序” only when unused D9/D10 remain; if those were already asked and the top two are still within one point, a line “这两分钟按现有信息分不开,参考题已经用过.”; then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold. On a normal convergence, unused style questions are asked before this card. Exhausted and closed-ceiling exits still deliver the card if a style question cannot be rendered, once the precision gate or the “nothing more” stop is met. | enabled | +| `guided-collect` | a named yes/no card then an entry card. A window whose boundary track carries a domain (D9 / D10 Narayana) asks “YYYY 年 M 到 M 月之间,有没有<那一类事>?”; every other window asks openly — “YYYY 年 M 到 M 月之间,有没有什么事,比如?” — and one window is asked once, whatever label it carries. The entry card is seven type chips (an open window defaults to the first still-open kind) plus a month-granularity date picker (day optional, years from birth year to this year); submitting writes “YYYY 年 M 月(D 日),<领域标签>方面有一件事”, never the question’s example list. Composer stays enabled. | enabled (typing still records), stop visible | | `adopting` | “正在采用 HH:MM…” through the follow-up turn | enabled (typing queues), stop visible | | `confirmed` | “已确认校正时间:HH:MM” | enabled | | `readonly` | “该校正已结束,只能查看历史。” and “再次校正” | disabled | diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index a615eaed..b5a7ac68 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -48,7 +48,9 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 生成中输入框变灰,回车没反应 | 生成中仍可打字;回车后显示「已排队,回答结束后发出」,可撤回 | 对标主流对话产品,焦点不丢。 | | 停止后出现红色告警条 | 已停止,已生成的内容保留;本次不会扣点。 | 停止是用户动作,不是出错。 | | 也可以再说一件你记得大概时间的事。 / 还差带月份的经历,领域不限。 | 训练门开后给区间卡,并写「如果还记得……范围还能再收一截」。材料不够时只写记下了哪几件、再来一件不是这类的、至少两个具体例子;输入框占位「再说一件带年月的事」。 | 训练门未开时永远给结果或精确缺口;不说「领域不限」「做不了」。交付后采集线关完的邀请见下一行。 | -| 能问的都问完了 / 你要是还记得确切哪一天的事,不限领域,说出来我接着算 | 门槛未达:「现在还剩 04:51–05:11 里 5 个候选,再对照几件经历会更准」,下面是系统点名的题(带年月区间)和年/月选择器。用户说「没有了」才出目前范围卡,卡上不再邀请打字。 | 精度不够就继续引导,不让用户自由发挥。不承诺「再补几件就能定到分钟」。 | +| 能问的都问完了 / 你要是还记得确切哪一天的事,不限领域,说出来我接着算 | 还有题可问时:「现在还剩 04:51–05:11 里 5 个候选,再对照几件经历会更准」,下面是系统点名的题(带年月区间)和年/月选择器。题问完了或用户说「没有了」,就出目前范围卡,卡上不再邀请打字。 | 有题就继续引导,不让用户自由发挥;没题了照常给结果,不写「未达门槛」。不承诺「再补几件就能定到分钟」。 | +| 2018 年 3 到 5 月之间,有没有入职、换工作或职责变重?(引擎其实不知道是哪一类) | 「2018 年 3 到 5 月之间,有没有什么事,比如搬家或开始长期住外地、开始认真关系、分手或结婚?」只有 D9 / D10 那两条轨道才点名领域。 | 边界只说得出「哪段时间」,说不出「哪一类事」。例子最多三个,只列用户还没拒答、也还没说过的领域。 | +| (录入卡提交)2019 年 3 月,开始认真关系、分手或结婚 | 2019 年 3 月,感情方面有一件事。 | 账本记的是用户选的那一类,不是题干里的三选一列表;不出现「或」。 | | 填报出生时间 05:10 / 与你的出生时间相差 7 分钟 | 医院记录:「出生记录时间 05:10」。家人记得:「你填的大概时间 05:10」「与你填的大概时间相差 7 分钟」。只知道时段:「你给的时间段」。 | 推算值不得称作「你的出生时间」。医院记录与目前范围不一致时只并列差值,不站队。 | | 你的出生记录不准,按校正结果来 / 这些经历说明记录写错了 / 校正结果无效 | 出生记录时间 04:40,默认仍按这个时间排盘。你说的这些经历指向另一段时间,相差 8 分钟。你可以改用校正结果,也可以继续用记录。 | 记录与校正区间冲突时记录优先,但两者必须并列。不得宣布任何一方无效——不说记录不准,也不说校正结果不作数。 | | (冲突时按钮仍写)更像这个 | 按钮写「改用校正结果」,按钮下面一行写「选它之后,排盘会从出生记录时间 04:40 换成 04:48」。 | 换掉的是什么要在动手的地方说清楚。不新增入口,仍是同一个采用按钮;采用仍不是确认。 | diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 90cf7e69..d72cc0c7 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -419,7 +419,7 @@ export default function Home() { const rectificationCardLabel = rectificationLoading ? RECTIFICATION_OPENING_LABEL : rectificationEntryLabels[rectificationCardAction]; - const rectificationDeclaredTime = declaredBirthTime(profile); + const rectificationDeclaration = { declaredTime: declaredBirthTime(profile), birthDate: profile.date || null }; const rectificationErrorMessage = rectificationError === "profile_incomplete" ? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。" : rectificationError; @@ -1733,8 +1733,7 @@ export default function Home() { {rectificationSurfaceOpen && rectificationCaseId && ( void selectSessionModel(modelId)} diff --git a/frontend/src/components/event-date-entry-card.tsx b/frontend/src/components/event-date-entry-card.tsx index cd2a6964..6bc5ff17 100644 --- a/frontend/src/components/event-date-entry-card.tsx +++ b/frontend/src/components/event-date-entry-card.tsx @@ -6,8 +6,9 @@ import { EventDatePicker, type EventDateValue } from "@/components/event-date-pi import { Button } from "@/components/ui/button"; import { COLLECT_KIND_ORDER, - KIND_ORAL, + GUIDED_ANY_DOMAIN, type CollectKind, + type GuidedWindowDomain, } from "@/lib/rectification-agentic/v9/collection-question-pool"; import { RANGE_DELIVERY_DOMAIN_LABEL } from "@/lib/rectification-agentic/user-copy"; @@ -18,11 +19,17 @@ export type EventDateEntrySubmit = Readonly<{ day: number | null; }>; +/** + * BUG-750: the card already knows the date and the domain the user picked, so + * it states the one fact. The old text pasted the question's option list + * ("开始认真关系、分手或结婚"), which the ledger then stored as the summary. + */ export function formatEventDateEntryMessage(input: EventDateEntrySubmit): string { const when = input.day ? `${input.year} 年 ${input.month} 月 ${input.day} 日` : `${input.year} 年 ${input.month} 月`; - return `${when},${KIND_ORAL[input.domain]}`; + const label = RANGE_DELIVERY_DOMAIN_LABEL[input.domain] ?? input.domain; + return `${when},${label}方面有一件事`; } export function eventDateYearRange( @@ -36,8 +43,17 @@ export function eventDateYearRange( return { minYear: Math.max(1900, maxYear - 80), maxYear }; } +export function resolveEntryDomain( + domain: GuidedWindowDomain, + openDomains: readonly CollectKind[] = COLLECT_KIND_ORDER, +): CollectKind { + if (domain !== GUIDED_ANY_DOMAIN) return domain; + return openDomains[0] ?? COLLECT_KIND_ORDER[0]; +} + export function EventDateEntryCard({ defaultDomain, + openDomains, defaultYear, defaultMonth, minYear, @@ -45,7 +61,8 @@ export function EventDateEntryCard({ disabled = false, onSubmit, }: { - readonly defaultDomain: CollectKind; + readonly defaultDomain: GuidedWindowDomain; + readonly openDomains?: readonly CollectKind[]; readonly defaultYear?: number; readonly defaultMonth?: number; readonly minYear: number; @@ -53,7 +70,9 @@ export function EventDateEntryCard({ readonly disabled?: boolean; readonly onSubmit: (value: EventDateEntrySubmit) => void; }) { - const [domain, setDomain] = useState(defaultDomain); + const [domain, setDomain] = useState( + resolveEntryDomain(defaultDomain, openDomains), + ); const initial = useMemo(() => { if (!defaultYear || defaultYear < minYear || defaultYear > maxYear) return null; const month = defaultMonth && defaultMonth >= 1 && defaultMonth <= 12 ? defaultMonth : 1; diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index be8f0238..912df112 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -202,6 +202,12 @@ export type RectificationDecision = Readonly<{ heldForTieBreak?: boolean; /** Declared-time source for copy. Missing means approximate. */ birthTimeSource?: "hospital_record" | "approximate" | "period_only"; + /** + * D1 as measured this turn (width ≤ 10, top-two display gap > 3, no exact + * tie). Reported, never a delivery condition on its own (D2). Null on the + * helper/unit path, which does not pass the flag. + */ + precisionGateMet?: boolean | null; }>; export type DecideRectificationInput = Readonly<{ @@ -305,6 +311,14 @@ export function isDatedDiscriminatorProbe( } export function decideRectification(input: DecideRectificationInput): RectificationDecision { + // D1 is reported on every exit, not just the delivering ones. + return { + ...decideRectificationInner(input), + precisionGateMet: input.precisionGateMet ?? null, + }; +} + +function decideRectificationInner(input: DecideRectificationInput): RectificationDecision { if (input.caseStage === "block_scan") { return decideBlockScan(input); } @@ -386,7 +400,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat if (coverageBlocks) { const engineOffers = input.engineCeiling.acceptanceAllowed || input.engineCeiling.proposeAllowed; - const narrowingOpen = stillNeedNarrowing(input) && !mayDeliverOnPrecision(input); + const narrowingOpen = stillNeedNarrowing(input); if ( stopClass?.kind !== "keep_collecting" && input.trainingGateOpen !== false @@ -420,11 +434,12 @@ export function decideRectification(input: DecideRectificationInput): Rectificat return holdoutValidation(separation, range, capability); } // coverageBlocks already collected when the training gate is closed. - // Uncovered collect lines keep collecting only while D1 is unmet - // (BUG-654 + precision gate). Personality still does not occupy this slot. + // Dated-pool empty is not delivery until refresh and targeted collect are + // exhausted (BUG-654). D6: the precision gate never short-circuits a + // collect line. Personality still does not occupy this slot. // Omitted flags mean the helper/unit path: do not wait. Production // decideFromDossier always passes explicit booleans. - if (stillNeedNarrowing(input) && !mayDeliverOnPrecision(input)) { + if (stillNeedNarrowing(input)) { return collect(separation, holdout, range, probe, waitToNarrowCapability(capability), stopReason); } if (stopClass?.kind === "exhausted") { @@ -499,7 +514,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat capability: rangeDeliveryCapability, }); } - if (input.datedMethodCollectOpen === true && !input.userStopped && !mayDeliverOnPrecision(input)) { + if (input.datedMethodCollectOpen === true && !input.userStopped) { return collect(separation, holdout, range, null, capability, stopReason); } if (confirmationAllowed) { @@ -643,17 +658,29 @@ function stillNeedNarrowing(input: DecideRectificationInput): boolean { } /** - * Exhausted / offer delivery is blocked until D1, the user stops, or the - * guided pool is empty. Omitted flags keep the helper/unit path delivering. + * D2 + D6. The precision gate only holds the card back **while there is still + * a question to ask**: delivery is `userStopped || everyLineAsked`, where + * `everyLineAsked` is the guided pool (windows + skip retry + uncovered + * domains), the engine refresh, and the targeted seven. Once all of them are + * asked out there is nothing left to collect, so the reader gets the card + * under the existing rules whether or not D1 is met — D2 says the card, the + * adopt button, and the three body lines do not change, and carry no + * "gate unmet" marker. `precisionGateMet` is still computed and published on + * the decision (see `RectificationDecision.precisionGateMet`), it just no + * longer decides the timing on its own: what changed this round is that there + * are more question sources, so "every line asked" happens later. + * + * Omitting both gate flags means the helper/unit path: the gate does not + * participate at all and the pre-gate (317e9f18) behaviour stands. */ export function mayDeliverOnPrecision(input: DecideRectificationInput): boolean { if (input.userStopped === true) return true; - if (input.guidedCollectExhausted === true) return true; - if (input.precisionGateMet === true) return true; if (input.precisionGateMet === undefined && input.guidedCollectExhausted === undefined) { return true; } - return false; + return input.guidedCollectExhausted === true + && input.refreshExhausted !== false + && input.targetedCollectExhausted !== false; } export function shouldHoldForTieBreak( @@ -1001,6 +1028,7 @@ export function publicNextAction(decision: RectificationDecision): Readonly<{ representative_time: string | null; credible_range: readonly [string, string] | null; stop_reason: EvidenceStopReason | null; + precision_gate_met: boolean | null; birth_time_source?: ProvenanceSource; }> { return { @@ -1017,6 +1045,7 @@ export function publicNextAction(decision: RectificationDecision): Readonly<{ representative_time: decision.representativeTime, credible_range: decision.credibleRange, stop_reason: decision.stopReason ?? null, + precision_gate_met: decision.precisionGateMet ?? null, birth_time_source: normalizeBirthTimeSource(decision.birthTimeSource), }; } diff --git a/frontend/src/lib/rectification-agentic/v9/case-status.ts b/frontend/src/lib/rectification-agentic/v9/case-status.ts index 5e7f2aa4..02c22f82 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-status.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-status.ts @@ -89,4 +89,5 @@ export function evidenceWritesAllowed( export const MAX_RESUMABLE_CASES_PER_USER = 1; export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification"; -export const RECTIFICATION_SKILL_VERSION = "10.0.27"; +// 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump +export const RECTIFICATION_SKILL_VERSION = "10.0.28"; diff --git a/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts b/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts index 2a433901..8d6308b1 100644 --- a/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts +++ b/frontend/src/lib/rectification-agentic/v9/collection-question-pool.ts @@ -631,8 +631,24 @@ export function parseTargetedCollectKind( return { domain, stage: match[2] === "year" ? "year" : "existence" }; } +/** + * BUG-749: Vimshottari / natal Narayana boundaries carry no domain, so the + * window is asked open instead of being pinned to one rotated domain. + */ +export const GUIDED_ANY_DOMAIN = "any"; + +export type GuidedWindowDomain = CollectKind | typeof GUIDED_ANY_DOMAIN; + +export function normalizeGuidedWindowDomain( + domain: string | null | undefined, +): GuidedWindowDomain | null { + const trimmed = (domain ?? "").trim(); + if (trimmed === GUIDED_ANY_DOMAIN) return GUIDED_ANY_DOMAIN; + return normalizeCollectKind(trimmed); +} + export type GuidedWindowRef = Readonly<{ - domain: CollectKind; + domain: GuidedWindowDomain; year: number; monthLo: number; monthHi: number; @@ -645,7 +661,7 @@ export function parseGuidedWindowQuestionId( const trimmed = (questionId ?? "").replace(/:(?:next|next2|next3)$/, "").trim(); const match = /^collect:guided:window:(\d{4}):(\d{1,2}):(\d{1,2}):([a-z_]+)(?::(year))?$/.exec(trimmed); if (!match) return null; - const domain = normalizeCollectKind(match[4]); + const domain = normalizeGuidedWindowDomain(match[4]); if (!domain) return null; const year = Number(match[1]); const monthLo = Number(match[2]); @@ -657,7 +673,7 @@ export function parseGuidedWindowQuestionId( } export function guidedWindowQuestionId( - window: Readonly<{ year: number; month_lo: number; month_hi: number; domain: CollectKind }>, + window: Readonly<{ year: number; month_lo: number; month_hi: number; domain: GuidedWindowDomain }>, stage: TargetedCollectStage = "existence", ): string { const base = `collect:guided:window:${window.year}:${window.month_lo}:${window.month_hi}:${window.domain}`; @@ -666,7 +682,7 @@ export function guidedWindowQuestionId( export function parseYearEntryQuestionId( questionId: string | null | undefined, -): Readonly<{ domain: CollectKind; year: number | null; month: number | null }> | null { +): Readonly<{ domain: GuidedWindowDomain; year: number | null; month: number | null }> | null { const guided = parseGuidedWindowQuestionId(questionId); if (guided?.stage === "year") { return { domain: guided.domain, year: guided.year, month: guided.monthLo }; @@ -678,14 +694,29 @@ export function parseYearEntryQuestionId( return null; } +/** Open-window examples: the first three still-open domains, table driven. */ +export const GUIDED_ANY_EXAMPLE_LIMIT = 3; + +export function guidedAnyExamples(openDomains: readonly CollectKind[]): string[] { + return openDomains + .filter((domain, index, list) => list.indexOf(domain) === index) + .slice(0, GUIDED_ANY_EXAMPLE_LIMIT) + .map((domain) => KIND_ORAL[domain]); +} + export function guidedWindowPrompt( - window: Readonly<{ year: number; month_lo: number; month_hi: number; domain: CollectKind }>, + window: Readonly<{ year: number; month_lo: number; month_hi: number; domain: GuidedWindowDomain }>, + openDomains: readonly CollectKind[] = COLLECT_KIND_ORDER, ): string { - const oral = KIND_ORAL[window.domain]; - if (window.month_lo === window.month_hi) { - return `${window.year} 年 ${window.month_lo} 月前后,有没有${oral}?`; + const span = window.month_lo === window.month_hi + ? `${window.year} 年 ${window.month_lo} 月前后` + : `${window.year} 年 ${window.month_lo} 到 ${window.month_hi} 月之间`; + if (window.domain === GUIDED_ANY_DOMAIN) { + const examples = guidedAnyExamples(openDomains); + if (examples.length === 0) return `${span},有没有什么事?`; + return `${span},有没有什么事,比如${examples.join("、")}?`; } - return `${window.year} 年 ${window.month_lo} 到 ${window.month_hi} 月之间,有没有${oral}?`; + return `${span},有没有${KIND_ORAL[window.domain]}?`; } export function targetedCollectQuestionId( @@ -873,16 +904,32 @@ export function pendingTargetedYearDomain( return null; } +/** Table driven: the first domain the user has neither declined nor covered. */ +export function firstOpenCollectKind( + evidence: readonly CollectionEvidence[] = [], + topics: readonly CollectionTopic[] = [], +): CollectKind { + const declined = collectDeclinedKinds(topics); + const covered = coveredCollectKinds(evidence); + return COLLECT_KIND_ORDER.find((kind) => !declined.has(kind) && !covered.has(kind)) + ?? COLLECT_KIND_ORDER[0]; +} + export function pendingGuidedYearWindow( topics: readonly CollectionTopic[] = [], evidence: readonly CollectionEvidence[] = [], ): GuidedWindowRef | null { const covered = coveredCollectKinds(evidence); for (const topic of topics) { - const window = parseGuidedWindowQuestionId(topicQuestionId(topic)); - if (!window || window.stage !== "existence") continue; + const parsed = parseGuidedWindowQuestionId(topicQuestionId(topic)); + if (!parsed || parsed.stage !== "existence") continue; if (topicStatus(topic) !== "resolved") continue; - if (covered.has(window.domain)) continue; + if (parsed.domain !== GUIDED_ANY_DOMAIN && covered.has(parsed.domain)) continue; + // BUG-749: an open window has no domain of its own; the entry card opens + // on the first domain still open so the chip is never a wrong guess. + const window: GuidedWindowRef = parsed.domain === GUIDED_ANY_DOMAIN + ? { ...parsed, domain: firstOpenCollectKind(evidence, topics) } + : parsed; const yearId = guidedWindowQuestionId({ year: window.year, month_lo: window.monthLo, @@ -961,21 +1008,22 @@ export type GuidedCollectWindow = Readonly<{ split: Readonly<{ left: number; right: number }>; }>; +/** + * BUG-749: one boundary window is one question. The key is the year plus the + * month span only — a window answered under any domain label is answered. + */ function windowAlreadyAsked( topics: readonly CollectionTopic[], window: GuidedCollectWindow, - domain: CollectKind, ): boolean { - const key = guidedWindowQuestionId({ - year: window.year, - month_lo: window.month_lo, - month_hi: window.month_hi, - domain, - }); return topics.some((topic) => { const status = topicStatus(topic); if (status === "active" || !status) return false; - return topicQuestionId(topic).replace(/:(?:next|next2|next3)$/, "") === key; + const ref = parseGuidedWindowQuestionId(topicQuestionId(topic)); + if (!ref || ref.stage !== "existence") return false; + return ref.year === window.year + && ref.monthLo === window.month_lo + && ref.monthHi === window.month_hi; }); } @@ -990,18 +1038,23 @@ export function guidedWindowPool( if (pendingTargetedYearDomain(declinedTopics, evidence)) return []; if (pendingGuidedYearWindow(declinedTopics, evidence)) return []; const declined = collectDeclinedKinds(declinedTopics); + const covered = coveredCollectKinds(evidence); + const openDomains = COLLECT_KIND_ORDER.filter((kind) => ( + !declined.has(kind) && !covered.has(kind) + )); const remainingLine = remainingGuidedLine(splitTimes, candidateCount ?? 0, credibleRange); const items: CollectionPoolItem[] = []; for (const window of windows) { - const domain = normalizeCollectKind(window.domain); - if (!domain || declined.has(domain)) continue; - if (windowAlreadyAsked(declinedTopics, window, domain)) continue; + const domain = normalizeGuidedWindowDomain(window.domain); + if (!domain) continue; + if (domain !== GUIDED_ANY_DOMAIN && declined.has(domain)) continue; + if (windowAlreadyAsked(declinedTopics, window)) continue; const prompt = guidedWindowPrompt({ year: window.year, month_lo: window.month_lo, month_hi: window.month_hi, domain, - }); + }, openDomains); items.push({ kind: "guided", guidedSource: "window", diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 32afb3da..86c914d6 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -559,12 +559,13 @@ function narrowingExhaustion( ? [separation.representativeTime, separation.representativeTime] as const : null); const percents = deliveryPercentsFromDossier(dossier, inference); + const refreshDone = options?.refreshExhausted ?? ( + attempted + || remainingLayers.length === 0 + || (inference?.refresh_count ?? 0) >= 1 + ); return { - refreshExhausted: options?.refreshExhausted ?? ( - attempted - || remainingLayers.length === 0 - || (inference?.refresh_count ?? 0) >= 1 - ), + refreshExhausted: refreshDone, targetedCollectExhausted: options?.targetedCollectExhausted ?? targetedCollectExhausted( remainingLayers, @@ -573,15 +574,17 @@ function narrowingExhaustion( live.remainingSplitTimes, live.remainingCandidateCount, ), + // F3/BUG-751: an empty guided pool is not "asked out" while the engine + // refresh has never been tried (BUG-654 / BUG-656). guidedCollectExhausted: options?.guidedCollectExhausted - ?? guidedCollectExhausted( + ?? (refreshDone && guidedCollectExhausted( remainingLayers, dossier.evidence, declined, live.guidedCollectWindows, live.remainingSplitTimes, live.remainingCandidateCount, - ), + )), precisionGateMet: options?.precisionGateMet ?? computePrecisionGateMet({ range, diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index e90ec81b..aaf263df 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -114,6 +114,9 @@ import { parseTargetedCollectKind, parseGuidedWindowQuestionId, guidedWindowQuestionId, + firstOpenCollectKind, + GUIDED_ANY_DOMAIN, + type GuidedWindowDomain, guidedWindowPool, guidedRetryPool, type CollectKind, @@ -1535,20 +1538,25 @@ export function targetedCollectYearFollowup(domain: CollectKind): MethodFollowup } function guidedYearFollowup(window: { - domain: CollectKind; + domain: GuidedWindowDomain; year: number; monthLo: number; monthHi: number; }): MethodFollowup { - const base = targetedCollectYearFollowup(window.domain); + // `pendingGuidedYearWindow` already resolves an open window to the first + // still-open domain; this keeps the type honest for any other caller. + const domain = window.domain === GUIDED_ANY_DOMAIN + ? firstOpenCollectKind() + : window.domain; + const base = targetedCollectYearFollowup(domain); return { ...base, - kind_hint: `guided:window:${window.domain}:year`, + kind_hint: `guided:window:${domain}:year`, collection_key: guidedWindowQuestionId({ year: window.year, month_lo: window.monthLo, month_hi: window.monthHi, - domain: window.domain, + domain, }, "year"), probe_year: window.year, probe_month: window.monthLo, diff --git a/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts b/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts index 08aa782a..64c0728f 100644 --- a/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts +++ b/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts @@ -200,13 +200,13 @@ function createdFocusFromArgs(args: Record) { test("collect-phase nonterminal exit persists a spoken collect when public can_adopt is closed", async () => { const decisionDossier = collectDecisionDossier(); const decision = decideFromDossier(decisionDossier, { birthDate: "1997-08-08" }); - // 原值: sessionOutcome=collect_evidence,publicCanAdopt=false,补写家人等 leftover 口述采集 - // 新值: 四件两类已开训练门,leftover 带年份轮转关闭,公开可采用,S3 交付 - // 原因: 训练门开后不再按未覆盖领域挡住采用(BUG-648) - assert.equal(decision.sessionOutcome, "adopt_representative"); - assert.equal(decision.nextAction, "offer_provisional_range"); - assert.equal(decision.canAdopt, true); - assert.equal(publicCanAdopt(decision), true); + // 原值: adopt_representative / offer_provisional_range / publicCanAdopt=true(BUG-648) + // 新值: collect_evidence / ask_fact_collection / publicCanAdopt=false + // 原因: D1 门槛(13 分钟、14:13:13 并列)+ D6「线问完才交付」, + // 本用例回到 BUG-648 之前的采集出口(BUG-751) + assert.equal(decision.sessionOutcome, "collect_evidence"); + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(publicCanAdopt(decision), false); const store: { focus: ReturnType | null } = { focus: null }; const warnings: string[] = []; @@ -229,15 +229,18 @@ test("collect-phase nonterminal exit persists a spoken collect when public can_a userId: USER_ID, caseId: CASE_ID, }); - assert.equal(repaired.persisted, false); + // 原值: persisted=false / 无 repair 警告 / 不写 focus(S3 交付轮) + // 新值: persisted=true / 写 repair 警告 / 写一条采集 focus + // 原因: 同上——本轮回到采集,非终结轮出口必须补一个问题(BUG-751) + assert.equal(repaired.persisted, true); } finally { console.warn = originalWarn; } - assert.equal(warnings.some((item) => item.includes("rectification_nonterminal_exit_repaired")), false); - assert.equal(store.focus, null); + assert.equal(warnings.some((item) => item.includes("rectification_nonterminal_exit_repaired")), true); + assert.notEqual(store.focus, null); assert.equal( accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), - false, + true, ); }); @@ -317,10 +320,13 @@ test("skipped opening collect other still yields education without collect_retry closedCollectFocuses: [OPENING_SKIPPED], sessionOutcome: "collect_evidence", }); - // 原值: 四件两类仍问家人采集 - // 新值: 训练门已开,不再 leftover 带年份轮转 - // 原因: S2/S3,开场 other 过期不恢复领域轮盘 - assert.equal(plan.next_followup, null); + // 原值: plan.next_followup = null(训练门开后不再轮转) + // 新值: 学业线的定向存在性题 collect:targeted:education + // 原因: D5——七条线按 COLLECT_KIND_ORDER 全部轮到,不再按分盘层剔除; + // 题名说的「yields education」正是这一条(BUG-751) + assert.equal(plan.next_followup?.domain, "education"); + assert.equal(plan.next_followup?.collection_key, "collect:targeted:education"); + assert.notEqual(plan.next_followup?.collect_retry, true); const educationPlan = buildMethodFollowupPlan({ evidence: [...COLLECT_EVIDENCE, family], @@ -335,10 +341,10 @@ test("skipped opening collect other still yields education without collect_retry sessionOutcome: "collect_evidence", precisionStage: "d5_refine", }); - // 原值: 自动落到 education 采集 - // 新值: 不是学业轮转;方法层仍可问占问 - // 原因: leftover 带年份采集关闭后,horary 仍可 collect_method_evidence - assert.equal(educationPlan.next_followup?.domain, "horary"); + // 原值: horary(leftover 带年份采集关闭后方法层轮到占问) + // 新值: education + // 原因: D5——定向七条线排在方法层之前,学业线仍未覆盖(BUG-751) + assert.equal(educationPlan.next_followup?.domain, "education"); assert.equal(educationPlan.next_followup?.intent, "collect_method_evidence"); }); diff --git a/frontend/tests/rectification-adopt-narration-20260904.test.ts b/frontend/tests/rectification-adopt-narration-20260904.test.ts index 75bdc457..67b7e4ee 100644 --- a/frontend/tests/rectification-adopt-narration-20260904.test.ts +++ b/frontend/tests/rectification-adopt-narration-20260904.test.ts @@ -1232,11 +1232,12 @@ test("family collect declined vs extra distinguish declined leaves the same adop nextAction: publicNextAction(right), birthDate: "1997-08-08", }); - // 原值: 两侧旁白逐字相同(同一条 2020 年工作锚点)/ 再改成两侧都是 S3 交付 - // 新值: 两侧都先定向补迁居;拒答区分题不得把一侧打回领域轮盘 - // 原因: BUG-654 按剩余换升层补事,区分题拒答不等于定向补事拒答 - assert.match(narratedLeft.hostNarration, /搬家|换城市|还能把剩下的候选分开/); - assert.match(narratedRight.hostNarration, /搬家|换城市|还能把剩下的候选分开/); + // 原值: /搬家|换城市|还能把剩下的候选分开/ + // 新值: 两侧都是「现在还剩 …里 N 个候选」的定向补事行 + // 原因: D5——七条线按 COLLECT_KIND_ORDER 轮到,多拒答一条区分题只少一条 + // 线(搬家),两侧仍都停在采集、都落库(BUG-751) + assert.match(narratedLeft.hostNarration, /现在还剩 .+ 里 \d+ 个候选/); + assert.match(narratedRight.hostNarration, /现在还剩 .+ 里 \d+ 个候选/); assert.equal(narratedLeft.persisted, true); assert.equal(narratedRight.persisted, true); }); diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index 434e2ecb..e02fe3b4 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -690,19 +690,15 @@ test("last structured choice emits the adoption range and persists the same narr optionId: "A", expectedRevision: adoptionInference().revision, }); - // Formerly offer_provisional_range: that depended on a 16/14 unresolved fixture being adoptable. - assert.equal(applied.nextAction.type, "ready_to_adopt"); - assert.equal(applied.nextAction.can_adopt, true); - assert.match(applied.narration, /05:\d{2}/); - // 原值: 「眼下更站得住的是|代表分钟」+ adoptCue - // 新值: 三句交付(目前范围 / 对照经历 / 边界句) - // 原因: BUG-597 决策 1,不预标排盘用 - assert.match(applied.narration, /目前范围/); + // 原值: ready_to_adopt / can_adopt=true / 三句交付正文 + // 新值: ask_fact_collection / can_adopt=false / 采集正文 + // 原因: D5 + D6——七条线还没问完,最后一道区分题答完仍先继续采集, + // 交付正文留给线问完且门槛达标那一轮(BUG-751) + assert.equal(applied.nextAction.type, "ask_fact_collection"); + assert.equal(applied.nextAction.can_adopt, false); assert.doesNotMatch(applied.narration, /排盘用/); - assert.equal(containsBoundarySemantics(applied.narration), true); assert.doesNotMatch(applied.narration, /方法1|Technique Audit/); const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn"); - assert.match(String(turn?.args.p_assistant_message ?? ""), /目前范围/); assert.doesNotMatch(String(turn?.args.p_assistant_message ?? ""), /排盘用/); }); @@ -975,27 +971,30 @@ test("answering the last discriminator persists a year-locked family collect foc expectedRevision: familyCollectInference().revision, }); const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus"); - // 原值: persist 家人采集焦点,题干带 2021 年前后年龄带 - // 新值: 训练门已开,不写 leftover 家人焦点;旁白是 S3 范围交付,不是无年份 D24 - // 原因: leftover 带年份轮转关闭(BUG-648);撤回年龄带年份(BUG-642) - assert.equal(setFocus, undefined); + // 原值: setFocus === undefined(训练门开后不写 leftover 焦点),旁白是 S3 交付 + // 新值: 写一条定向采集焦点 collect:targeted:education,旁白仍不是无年份 D24 + // 原因: D5——七条线全部轮到;D6——线没问完不交付(BUG-751)。 + // 题名要的「年份锁定的采集焦点,而不是无年份 D24 卡」仍然成立。 + assert.notEqual(setFocus, undefined); + assert.equal(setFocus?.args.p_question_id, "collect:targeted:education"); + assert.equal(setFocus?.args.p_intent, "collect_method_evidence"); assert.equal(applied.nextInterviewPersisted, true); - assert.equal(applied.nextChoiceReady, false); - assert.equal(shouldContinueAfterStructuredChoice(applied.nextAction, applied), false); assert.match(applied.narration, /已记录,/); - assert.match(applied.narration, /目前范围/); assert.doesNotMatch(applied.narration, /2021 年前后/); - assert.doesNotMatch(applied.narration, /点选/); assert.doesNotMatch(applied.narration, /D24/); const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn"); assert.match(String(turn?.args.p_assistant_message ?? ""), /已记录,/); assert.doesNotMatch(String(turn?.args.p_assistant_message ?? ""), /2021 年前后/); + // 原值: 没有任何 focus 写入带 p_asked_turn_id === TURN_ID + // 新值: 写入的 focus 只能是新问题本身(linkFocusAskedTurn 把新问题挂到 + // 本轮刚追加的 assistant turn 上;夹具让这个 id 恰好等于 TURN_ID) + // 原因: D5/D6 让本轮重新落一条采集焦点,旧断言在这个夹具里已无法区分 + // 「答过的轮次」和「刚发出的轮次」(BUG-751) assert.equal( - accounting.calls.some((call) => ( - call.fn === "set_agentic_rectification_conversation_focus" - && call.args.p_asked_turn_id === TURN_ID - )), - false, + accounting.calls + .filter((call) => call.fn === "set_agentic_rectification_conversation_focus") + .every((call) => call.args.p_question_id === "collect:targeted:education"), + true, ); }); diff --git a/frontend/tests/rectification-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index ff99b8a1..a5e3eeda 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -803,10 +803,12 @@ test("family coverage without a renderable discriminator still collects the next precisionStage: "d4_refine", birthDate: "1997-08-08", }); - // 原值: leftover 财务采集 d2_finance - // 新值: 训练门已开,不再 leftover 带年份轮转;本夹具下一问为空 - // 原因: 覆盖家人后进入 S2/S3,不按领域轮盘补问(BUG-648) - assert.equal(plan.next_followup, null); + // 原值: plan.next_followup = null + // 新值: 迁居线的定向存在性题 + // 原因: D5——七条线按 COLLECT_KIND_ORDER 全部轮到;题名要的 + // 「still collects the next dated domain」正是这一条(BUG-751) + assert.equal(plan.next_followup?.domain, "relocation"); + assert.equal(plan.next_followup?.collection_key, "collect:targeted:relocation"); }); test("GET A/B card stays visible after coverage when candidates remain tied", () => { diff --git a/frontend/tests/rectification-collect-direction-20260904.test.ts b/frontend/tests/rectification-collect-direction-20260904.test.ts index 65c8eabe..e8b137e7 100644 --- a/frontend/tests/rectification-collect-direction-20260904.test.ts +++ b/frontend/tests/rectification-collect-direction-20260904.test.ts @@ -30,6 +30,7 @@ import { } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts"; import { turnQuestionKind } from "../src/lib/rectification-agentic/v9/turn-question.ts"; +import { TARGETED_EXISTENCE_PROMPT } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts"; import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts"; import { CANDIDATE_ID, @@ -242,8 +243,16 @@ test("holdout remaining domain uses the server collect stem, not a reverse-verif }); assert.equal(plan.next_followup?.domain, "education"); assert.equal(plan.next_followup?.intent, "collect_method_evidence"); - assert.equal(plan.next_followup?.choice_frame, null); - assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.education); + // 原值: choice_frame === null(纯口述采集题) + // 新值: 定向存在性题的 A/B/C/D frame + // 原因: D5/T4b——七条线统一走「先问有没有」的点选题;题名要的 + // 「用服务器的采集题干、不改写成反向核对」由下面两条仍然保证(BUG-751) + assert.equal(plan.next_followup?.choice_frame?.choice_kind, "existence"); + // 原值: USER_COLLECT_QUESTION.education(「上学这边,还记得哪年…」) + // 新值: TARGETED_EXISTENCE_PROMPT.education(「学业上有没有过…哪一年都算?」) + // 原因: T4b——存在性题问整个领域、任何时间;仍是服务器题干,不是反向核对 + assert.equal(spokenFollowupForUser(plan.next_followup), TARGETED_EXISTENCE_PROMPT.education); + assert.doesNotMatch(String(spokenFollowupForUser(plan.next_followup)), /对不对|是不是这样/); assert.equal(turnQuestionKind({ intent: plan.next_followup?.intent, expectedAnswerSchema: { prompt: spokenFollowupForUser(plan.next_followup), collect: true }, @@ -396,6 +405,8 @@ test("persistNextInterviewAfterChoice after family denial asks the invite, not o representative_time: null, credible_range: null, stop_reason: null, + // 新增字段:D1 现在随每个出口一起上报(见 publicNextAction) + precision_gate_met: null, }, }); // 原值: education 通用采集 @@ -427,10 +438,11 @@ test("four scoreable events stop dated collect and leave remaining domains to S2 oosBlindPrompts: [], candidatesSeparated: true, }); - // 原值: 训练门开后仍收 relocation - // 新值: 训练门开后不再按领域轮转采集 - // 原因: S1 只问到训练门开;S2 走选择题或交付(BUG-648) - assert.notEqual(plan.next_followup?.intent, "collect_method_evidence"); + // 原值: next_followup.intent != collect_method_evidence + // 新值: 仍是 collect_method_evidence,但走的是定向七条线(不是旧的领域轮盘) + // 原因: D5——七条线按 COLLECT_KIND_ORDER 全部轮到;D6——线问完才交付(BUG-751) + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.match(String(plan.next_followup?.collection_key ?? ""), /^collect:(targeted|guided):/); }); test("set-focus with a domainless collect prompt does not invent an education stem", async () => { @@ -801,14 +813,15 @@ test("separated candidates stop leftover dated collect once the training gate is const dossier = parseV9CaseDossier(raw); assert.ok(dossier); const decision = decideFromDossier(dossier); - // 原值: 仍采集 finance,sessionOutcome=collect_evidence - // 新值: 四件三类已开训练门,可交付 - // 原因: S1 只问到训练门开(BUG-648) - assert.notEqual(decision.sessionOutcome, "collect_evidence"); - assert.notEqual(decision.nextAction, "ask_fact_collection"); + // 原值: sessionOutcome != collect_evidence、nextAction != ask_fact_collection + // 新值: collect_evidence / ask_fact_collection,且下一问是定向线 + // 原因: D5 + D6——只拒答了家人,其余六条线仍要问(BUG-751) + assert.equal(decision.sessionOutcome, "collect_evidence"); + assert.equal(decision.nextAction, "ask_fact_collection"); const { plan, userAction } = nextActionFromDecision(dossier, decision); - assert.notEqual(plan.next_followup?.intent, "collect_method_evidence"); - assert.notEqual(userAction.id, "ask_method_followup"); + assert.equal(plan.next_followup?.intent, "collect_method_evidence"); + assert.match(String(plan.next_followup?.collection_key ?? ""), /^collect:(targeted|guided):/); + assert.equal(userAction.id, "ask_method_followup"); }); test("dated collect plus occupation close lets one round offer without another question", async () => { @@ -846,13 +859,16 @@ test("offer-candidates is allowed once remaining dated collect is closed by the turnId: TURN_ID, accounting: accounting.client as never, }); - // 原值: remaining dated collect 仍开 → offer_not_allowed - // 新值: 训练门开后不再挡 offer - // 原因: S1 只问到训练门开(BUG-648) - const result = await (tools["rectification-offer-candidates"] as unknown as { - execute(input: unknown): Promise; - }).execute({ caseId: CASE_ID }); - assert.ok(result); + // 原值: offer 允许(BUG-648 训练门开即可) + // 新值: offer_not_allowed + // 原因: D6——定向线没问完,offer 仍被挡;题名的「closed by the training + // gate」在 D5 之后不再成立(BUG-751) + await assert.rejects( + () => (tools["rectification-offer-candidates"] as unknown as { + execute(input: unknown): Promise; + }).execute({ caseId: CASE_ID }), + /offer_not_allowed/, + ); }); const SEVEN_EVENTS: MethodFollowupEvidence[] = [ diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index 80298c7d..a0e3689d 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -464,7 +464,8 @@ test("skill version is 10.0.26 after the targeted-collect-cards bump", () => { // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28"); }); test("revision 5 with uncovered relatives asks the dated family collect, not a yearless D12 card", () => { @@ -1126,6 +1127,13 @@ test("nonterminal turn exit is already satisfied once dated coverage can adopt", { target_domain: "relocation", status: "declined", intent: "collect_method_evidence" }, { target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" }, { target_domain: "occupation", status: "declined", intent: "collect_method_evidence" }, + // 原值: 只拒答五条线 + // 新值: 追加 education / career / relationship + // 原因: D5 让七条线全部轮到;本用例测的是「可采用之后非终结出口已满足」, + // 所以夹具把七条线全部关掉(BUG-751) + { target_domain: "education", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "career", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "relationship", status: "declined", intent: "collect_method_evidence" }, ]; const next = decideFromDossier({ ...decision, @@ -1137,9 +1145,14 @@ test("nonterminal turn exit is already satisfied once dated coverage can adopt", { target_domain: "relocation", status: "declined", intent: "collect_method_evidence" }, { target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" }, { target_domain: "occupation", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "education", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "career", status: "declined", intent: "collect_method_evidence" }, + { target_domain: "relationship", status: "declined", intent: "collect_method_evidence" }, ], }, }, { birthDate: "1997-08-08" }); + // 断言不变(D2):七条线全部拒答之后没有题可问了,即使区间仍是 + // 04:53–05:07(14 分钟、门槛未达)也照现行规则出卡,不再写新问题。 assert.equal(publicCanAdopt(next), true); const accounting = fakeAccounting({ ...receiptHandlers, diff --git a/frontend/tests/rectification-confirmation-gate.test.ts b/frontend/tests/rectification-confirmation-gate.test.ts index 81358b4b..b76885a1 100644 --- a/frontend/tests/rectification-confirmation-gate.test.ts +++ b/frontend/tests/rectification-confirmation-gate.test.ts @@ -365,7 +365,8 @@ test("holdout not_ready forbids unique-minute copy and still blocks confirm", as // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28"); const accounting = fakeAccounting({ ...receiptHandlers, diff --git a/frontend/tests/rectification-convergence-budget.test.ts b/frontend/tests/rectification-convergence-budget.test.ts index abbb3de3..94d94a40 100644 --- a/frontend/tests/rectification-convergence-budget.test.ts +++ b/frontend/tests/rectification-convergence-budget.test.ts @@ -427,15 +427,19 @@ test("dossier and post-inference decisions share the half-uncertain stop rule", choiceAnswer("probe-1", 2020, "unsure"), choiceAnswer("probe-2", 2021, "yes"), ]); - // 原值: stopReason=null,继续区分 - // 新值: probes=[] 时 S2 因探针池空停 probe_pool_exhausted - // 原因: BUG-646 S2 停条件含探针池空 - assert.equal(decideFromDossier(dossier(rows, halfUncertain)).stopReason, "probe_pool_exhausted"); - assert.equal(decideAfterInferenceChange({ + // 原值: stopReason="probe_pool_exhausted"(BUG-646 探针池空即停) + // 新值: stopReason=null,nextAction=ask_fact_collection + // 原因: D6——探针池空只是没有点选题,采集线还没问完就不算停;两条路径 + // 仍然同口径(题名要的「share the half-uncertain stop rule」)(BUG-751) + const halfFromDossier = decideFromDossier(dossier(rows, halfUncertain)); + const halfAfterInference = decideAfterInferenceChange({ dossier: dossier(rows), state: halfUncertain, userStopped: false, - }).stopReason, "probe_pool_exhausted"); + }); + assert.equal(halfFromDossier.stopReason ?? null, null); + assert.equal(halfAfterInference.stopReason ?? null, halfFromDossier.stopReason ?? null); + assert.equal(halfFromDossier.nextAction, halfAfterInference.nextAction); const threeAnswersTwoUnsure = inferenceState([ choiceAnswer("probe-1", 2020, "unsure"), diff --git a/frontend/tests/rectification-coverage-collect.test.ts b/frontend/tests/rectification-coverage-collect.test.ts index 51269bf4..a34c1dd9 100644 --- a/frontend/tests/rectification-coverage-collect.test.ts +++ b/frontend/tests/rectification-coverage-collect.test.ts @@ -151,10 +151,11 @@ test("separated scores with uncovered relatives stay in collection and still ask const decision = decideFromDossier(dossier); assert.equal(decision.separation.sufficient, true); - // 原值: leftover 家人采集挡住,nextAction=ask_fact_collection - // 新值: 训练门已开,leftover 带年份轮转关闭,S3 offer_provisional_range - // 原因: 未覆盖 relatives 不再强制领域轮盘采集(BUG-648) - assert.equal(decision.nextAction, "offer_provisional_range"); + // 原值: offer_provisional_range + // 新值: ask_fact_collection + // 原因: D5 让七条线全部轮到 + D6 要求所有线问完才交付;未覆盖的 + // relatives / 搬家等仍是待问的线,出卡门槛只是必要条件(BUG-751) + assert.equal(decision.nextAction, "ask_fact_collection"); assert.equal(decision.canConfirmExactMinute, false); const plan = collectionFollowup(dossier); @@ -168,31 +169,30 @@ test("separated scores with uncovered relatives stay in collection and still ask test("covering family without occupation still opens adopt after scores already separated", () => { const familyOnly = producedDossier([...TRAINING_EVIDENCE, FAMILY_EVIDENCE]); const familyDecision = decideFromDossier(familyOnly.dossier); - // 原值: leftover 财务/搬家/健康/职业口述未问完保持 ask_fact_collection - // 新值: leftover 带年份轮转关闭,分数已分开则 S3 ready_to_adopt - // 原因: 覆盖家人不再要求继续领域轮盘(BUG-648) - assert.equal(familyDecision.nextAction, "ready_to_adopt"); - assert.equal(familyDecision.sessionOutcome, "adopt_representative"); + // 原值: ready_to_adopt / adopt_representative / canAdopt=true + // 新值: ask_fact_collection / collect_evidence(canAdopt 仍是引擎能力位,不变) + // 原因: D5 + D6——财务、搬家、健康、职业四条线还没问,交付要等问完(BUG-751) + assert.equal(familyDecision.nextAction, "ask_fact_collection"); + assert.equal(familyDecision.sessionOutcome, "collect_evidence"); assert.equal(familyDecision.canAdopt, true); assert.equal(familyDecision.canConfirmExactMinute, false); }); test("covering family and occupation still opens adopt after scores already separated", () => { const { state, dossier: collecting } = producedDossier(TRAINING_EVIDENCE); - // 原值: leftover 家人采集挡住 collecting=ask_fact_collection - // 新值: leftover 带年份轮转关闭,S3 offer_provisional_range - // 原因: 训练门开后不再按未覆盖领域挡住采用(BUG-648) - assert.equal(decideFromDossier(collecting).nextAction, "offer_provisional_range"); + // 原值: offer_provisional_range + // 新值: ask_fact_collection + // 原因: D5 + D6——七条线还没问完(BUG-751) + assert.equal(decideFromDossier(collecting).nextAction, "ask_fact_collection"); const covered = [...TRAINING_EVIDENCE, FAMILY_EVIDENCE, OCCUPATION_EVIDENCE]; const { dossier } = producedDossier(covered, { previous: state }); const decision = decideFromDossier(dossier); - assert.ok( - decision.nextAction === "ready_to_adopt" || decision.nextAction === "offer_provisional_range", - `expected S3, got ${decision.nextAction}`, - ); - assert.notEqual(decision.nextAction, "ask_fact_collection"); - assert.notEqual(decision.sessionOutcome, "collect_evidence"); + // 原值: S3(ready_to_adopt 或 offer_provisional_range)、canAdopt=true + // 新值: ask_fact_collection(canAdopt 仍是引擎能力位,不变) + // 原因: 同上——覆盖家人与职业后,剩下五条线仍要问(BUG-751) + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.sessionOutcome, "collect_evidence"); assert.equal(decision.canAdopt, true); assert.equal(decision.canConfirmExactMinute, false); assert.equal(decision.separation.sufficient, true); @@ -210,11 +210,11 @@ test("declining family and occupation also opens adopt after scores already sepa assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "covered"); const decision = decideFromDossier(dossier); - // 原值: leftover 带年份轮转仍 ask_fact_collection - // 新值: 拒答家人与职业后 leftover dated 关闭,S3 ready_to_adopt - // 原因: 训练门开后不再按领域轮盘补问(BUG-648) - assert.equal(decision.nextAction, "ready_to_adopt"); - assert.equal(decision.sessionOutcome, "adopt_representative"); + // 原值: ready_to_adopt / adopt_representative / canAdopt=true + // 新值: ask_fact_collection / collect_evidence(canAdopt 仍是引擎能力位,不变) + // 原因: D5 + D6——只拒答了家人与职业,其余五条线仍在引导池里(BUG-751) + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.sessionOutcome, "collect_evidence"); assert.equal(decision.canAdopt, true); assert.equal(decision.canConfirmExactMinute, false); }); diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index fe30a664..4f7e6a9e 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -707,6 +707,10 @@ test("public decision fields are derived from decideRectification", () => { credible_range: decision.credibleRange, // 旧 公开字段无 stop_reason → 新 只读暴露停止原因 → 保留 采用/确认门字段语义不变 stop_reason: decision.stopReason ?? null, + // 原值: 公开字段集合不含 precision_gate_met + // 新值: 含 precision_gate_met(helper 路径不传 flag,值为 null) + // 原因: D1 只上报不决定出卡时机,必须随每个出口投影出去(BUG-751) + precision_gate_met: decision.precisionGateMet ?? null, // 原值: 公开字段集合不含 birth_time_source // 新值: 与 publicNextAction 同集,含 birth_time_source // 原因: BUG-690 投影带来源标签;BUG-692 深比较跟上,不得改成部分匹配 diff --git a/frontend/tests/rectification-delivery-report-facts.test.ts b/frontend/tests/rectification-delivery-report-facts.test.ts index 1032316b..6310dd40 100644 --- a/frontend/tests/rectification-delivery-report-facts.test.ts +++ b/frontend/tests/rectification-delivery-report-facts.test.ts @@ -258,13 +258,15 @@ test("skill 10.0.26 forbids computing varga signs from transition times", () => // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28"); // 原值: "10.0.25" // 原值: "10.0.26" // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.match(skill, /^version: 10\.0\.27$/m); + // 原值: /^version: 10\.0\.27$/ / 新值: /^version: 10\.0\.28$/ / 原因: D6 改写出卡句后 Skill bump + assert.match(skill, /^version: 10\.0\.28$/m); assert.match(skill, new RegExp(SKILL_SIGN_SENTENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); assert.match(comparison, new RegExp(SKILL_SIGN_SENTENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); }); diff --git a/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts b/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts index 66e81f0e..739c6ed4 100644 --- a/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts +++ b/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts @@ -309,8 +309,12 @@ test("POST idle persist and GET decideFromDossier agree on a first-place tie", a }); const getDelivers = sessionOutcomeAllowsDelivery(getDecision.sessionOutcome); const postDelivers = idle.terminalNote === true && Boolean(idle.hostNarration); + // 原值: getDelivers=true(头名并列即交付) + // 新值: getDelivers=false + // 原因: D1——并列时 precision_gate_met 恒为假;D6——门槛未达不交付。 + // 题名要的「POST 与 GET 口径一致」仍由下面一条保证(BUG-751) assert.equal(getDecision.stopReason, "tied_first"); - assert.equal(getDelivers, true); + assert.equal(getDelivers, false); assert.equal(postDelivers, getDelivers); }); diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index eb86ec30..cc657368 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -1454,7 +1454,8 @@ test("public tool surface stays at 14 and new cases bind 10.0.26", () => { // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28"); const deprecated = resolveExactSkillPackage( "jyotish-birth-time-rectification", "10.0.2", @@ -3129,13 +3130,15 @@ test("offer-candidates may proceed once method coverage leftover collect is clos turnId: TURN_ID, accounting: accounting.client as never, }); - // 原值: 三件三类仍因方法覆盖挡 offer - // 新值: 训练门开后 leftover 采集已关,offer 可走 - // 原因: BUG-648 - const result = await (tools["rectification-offer-candidates"] as unknown as { - execute(input: unknown): Promise; - }).execute({ caseId: CASE_ID }); - assert.ok(result); + // 原值: offer 可走(BUG-648 训练门开即可) + // 新值: offer_not_allowed + // 原因: D5 + D6——定向七条线仍未问完,offer 继续被挡(BUG-751) + await assert.rejects( + () => (tools["rectification-offer-candidates"] as unknown as { + execute(input: unknown): Promise; + }).execute({ caseId: CASE_ID }), + /offer_not_allowed/, + ); }); test("paused case with selection_allowed may offer the escape hatch", async () => { diff --git a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts index 067d6d33..7fbcb4a4 100644 --- a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts +++ b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts @@ -602,7 +602,8 @@ test("skill version is 10.0.26 after the targeted-collect-cards bump", () => { // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28"); }); test("USER_COLLECT_QUESTION no longer has an other fallback", () => { @@ -657,9 +658,14 @@ test("collect spoken stop stays on choice cards; range line is status only", () test("covered accident shape adopts instead of collecting other", async () => { const dossier = accidentDossier(); const decision = decideFromDossier(dossier, { birthDate: "1998-03-15" }); - assert.notEqual(decision.sessionOutcome, "collect_evidence"); - assert.equal(decision.sessionOutcome, "adopt_representative"); - assert.match(decision.stopReason ?? "", /probe_pool_exhausted/); + // 原值: sessionOutcome=adopt_representative + // 新值: collect_evidence + // 原因: D6——探针池空之后还有定向线要问,门槛也未达(BUG-751) + assert.equal(decision.sessionOutcome, "collect_evidence"); + // 原值: stopReason 含 probe_pool_exhausted + // 新值: stopReason 为空 + // 原因: 同上——本轮不是停,是继续采集(BUG-751) + assert.equal(decision.stopReason ?? null, null); const accounting = idleHandlers(dossier); const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({ @@ -674,10 +680,12 @@ test("covered accident shape adopts instead of collecting other", async () => { // 原因: D1 / D3 assert.doesNotMatch(persisted.hostNarration ?? "", /领域不限|做不了|材料不够/); assert.doesNotMatch(persisted.hostNarration ?? "", /排盘用/); - assert.match(persisted.hostNarration ?? "", /对照了 7 件经历/); + // 原值: 三句交付正文(对照了 7 件经历 + 边界句)、terminalNote=true + // 新值: 采集旁白「现在还剩 HH:MM–HH:MM 里 N 个候选,再对照几件经历会更准」 + // 原因: D1/D6——这个夹具的区间未达门槛,本轮继续引导而不是交付(BUG-751) + assert.match(persisted.hostNarration ?? "", /现在还剩 .+ 里 \d+ 个候选/); assert.equal((persisted.hostNarration ?? "").includes("也可以再" + "说一件"), false); - assert.match(persisted.hostNarration ?? "", /这只是代表性候选,不是已确认的唯一出生分钟/); - assert.equal(persisted.terminalNote, true); + assert.doesNotMatch(persisted.hostNarration ?? "", /你要是还记得确切哪一天/); }); test("closed ceiling with training open delivers a range and a concrete next-collect hint", async () => { diff --git a/frontend/tests/rectification-guided-collect-20260916.test.ts b/frontend/tests/rectification-guided-collect-20260916.test.ts index ebe7ba08..a028c3ff 100644 --- a/frontend/tests/rectification-guided-collect-20260916.test.ts +++ b/frontend/tests/rectification-guided-collect-20260916.test.ts @@ -3,7 +3,10 @@ import test from "node:test"; import { COLLECT_KIND_ORDER, + GUIDED_ANY_DOMAIN, GUIDED_NARROW_HINT, + firstOpenCollectKind, + guidedAnyExamples, TARGETED_EXISTENCE_PROMPT, TARGETED_EXISTENCE_PROMPT_RETRY, guidedCollectExhausted, @@ -23,7 +26,11 @@ import { GUIDED_WINDOW_OPTION_B, } from "../src/lib/rectification-agentic/v9/choice-card.ts"; import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; -import { eventDateYearRange, formatEventDateEntryMessage } from "../src/components/event-date-entry-card.tsx"; +import { + eventDateYearRange, + formatEventDateEntryMessage, + resolveEntryDomain, +} from "../src/components/event-date-entry-card.tsx"; const WINDOWS: readonly GuidedCollectWindow[] = [ { year: 2018, month_lo: 3, month_hi: 6, domain: "career", split: { left: 2, right: 3 } }, @@ -190,9 +197,68 @@ test("window prompt uses oral phrases, not server labels", () => { }); test("date entry submit walks the dated evidence path as year-month text", () => { + // 原值: "2018 年 3 月,开始认真关系、分手或结婚" + // 新值: "2018 年 3 月,感情方面有一件事" + // 原因: F6/BUG-750——提交文本不得是选项列表,账本 summary 不得含「或」 assert.equal( formatEventDateEntryMessage({ domain: "relationship", year: 2018, month: 3, day: null }), - "2018 年 3 月,开始认真关系、分手或结婚", + "2018 年 3 月,感情方面有一件事", + ); + assert.doesNotMatch( + formatEventDateEntryMessage({ domain: "relationship", year: 2018, month: 3, day: 12 }), + /或/, + ); + assert.equal( + formatEventDateEntryMessage({ domain: "family", year: 2019, month: 1, day: 5 }), + "2019 年 1 月 5 日,家人方面有一件事", + ); +}); + +test("open windows ask without a domain and list open-domain orals", () => { + const open = guidedWindowPrompt({ + year: 2018, + month_lo: 3, + month_hi: 6, + domain: GUIDED_ANY_DOMAIN, + }, ["relocation", "relationship", "family", "finance"]); + assert.equal( + open, + "2018 年 3 到 6 月之间,有没有什么事,比如搬家或开始长期住外地、开始认真关系、分手或结婚、添丁、长辈住院或做过手术?", + ); + assert.equal(guidedAnyExamples(["relocation", "relationship", "family", "finance"]).length, 3); + assert.equal( + guidedWindowPrompt({ year: 2018, month_lo: 3, month_hi: 3, domain: GUIDED_ANY_DOMAIN }, []), + "2018 年 3 月前后,有没有什么事?", + ); +}); + +test("an open window keeps its question id and is asked once for any domain", () => { + const pool = guidedWindowPool( + [{ year: 2018, month_lo: 3, month_hi: 6, domain: GUIDED_ANY_DOMAIN, split: { left: 2, right: 3 } }], + [], + [], + ); + assert.equal(pool.length, 1); + assert.equal(pool[0]?.domain, GUIDED_ANY_DOMAIN); + assert.equal(pool[0]?.key, "collect:guided:window:2018:3:6:any"); + // The same window answered under a domain label is the same window. + const asked = guidedWindowPool( + [{ year: 2018, month_lo: 3, month_hi: 6, domain: GUIDED_ANY_DOMAIN, split: { left: 2, right: 3 } }], + [], + [{ questionId: "collect:guided:window:2018:3:6:career", status: "resolved" }], + ); + assert.equal(asked.length, 0); +}); + +test("the entry card opens on the first still-open domain for an open window", () => { + assert.equal(resolveEntryDomain(GUIDED_ANY_DOMAIN, ["finance", "family"]), "finance"); + assert.equal(resolveEntryDomain("relationship", ["finance", "family"]), "relationship"); + assert.equal( + firstOpenCollectKind( + [{ status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2014-09-01", occurredTo: "2014-09-01" }], + [{ target_domain: "career", status: "declined" }], + ), + "relocation", ); }); diff --git a/frontend/tests/rectification-holdout-renderable.test.ts b/frontend/tests/rectification-holdout-renderable.test.ts index f5944899..bede06f2 100644 --- a/frontend/tests/rectification-holdout-renderable.test.ts +++ b/frontend/tests/rectification-holdout-renderable.test.ts @@ -179,10 +179,13 @@ test("sticky holdout with unknown precision stays review-only", () => { const decision = decideFromDossier(dossier); assert.notEqual(decision.nextAction, "ask_holdout_validation"); - // 原断言 offer_provisional_range + canAdopt=false → 新断言可采用的区间出口。 - // 为什么:holdout unavailable 只挡唯一分钟确认,不挡代表性采用。 - assert.ok(decision.nextAction === "offer_provisional_range" || decision.nextAction === "ready_to_adopt"); - assert.equal(decision.sessionOutcome, "adopt_representative"); + // 原值: offer_provisional_range / ready_to_adopt、adopt_representative、canAdopt=true + // 新值: ask_fact_collection、collect_evidence + // 原因: D5 + D6——holdout 变成无日期后这条线回到采集池,线没问完不交付; + // 题名要的「stays review-only」(不进 validated_range)仍成立; +// canAdopt 是引擎能力位,公开侧由 canOfferRange 收起(BUG-751) + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.sessionOutcome, "collect_evidence"); assert.notEqual(decision.sessionOutcome, "validated_range"); assert.equal(decision.validated, false); assert.equal(decision.canAdopt, true); diff --git a/frontend/tests/rectification-ingest-p0.test.ts b/frontend/tests/rectification-ingest-p0.test.ts index cae492aa..79800297 100644 --- a/frontend/tests/rectification-ingest-p0.test.ts +++ b/frontend/tests/rectification-ingest-p0.test.ts @@ -219,13 +219,15 @@ test("new-case skill identity is 10.0.26 and the prompt prefers batch ingest", ( // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28"); // 原值: "10.0.25" // 原值: "10.0.26" // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.match(skill, /^version: 10\.0\.27$/m); + // 原值: /^version: 10\.0\.27$/ / 新值: /^version: 10\.0\.28$/ / 原因: D6 改写出卡句后 Skill bump + assert.match(skill, /^version: 10\.0\.28$/m); assert.match(skill, /不要对同一句用户消息里的多件事件逐条 propose\+confirm/); assert.match(agentSource, /新事件走 rectification-record-evidence-batch/); assert.doesNotMatch(agentSource, /分别调用 rectification-propose-evidence 和 rectification-confirm-evidence/); diff --git a/frontend/tests/rectification-occupation-coverage-exit.test.ts b/frontend/tests/rectification-occupation-coverage-exit.test.ts index 89122b3b..c3643e4f 100644 --- a/frontend/tests/rectification-occupation-coverage-exit.test.ts +++ b/frontend/tests/rectification-occupation-coverage-exit.test.ts @@ -190,7 +190,8 @@ test("skill version is 10.0.26 after the targeted-collect-cards bump", () => { // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28"); }); test("nineteen-row ledger opens the training gate with four scoreable domains", () => { @@ -398,11 +399,12 @@ test("decideFromDossier offers a range with adopt when training is complete and }, case: { acceptedTime: null }, }); - // 旧:财务/健康/职业口述未问完保持采集。 - // 新:训练门开后职业未覆盖不再挡交付(BUG-648) - assert.equal(decision.canOfferRange, true); - assert.notEqual(decision.sessionOutcome, "collect_evidence"); - assert.notEqual(decision.nextAction, "ask_fact_collection"); + // 原值: canOfferRange=true,sessionOutcome != collect_evidence + // 新值: canOfferRange=false,collect_evidence / ask_fact_collection + // 原因: D5 + D6——职业等线未覆盖时仍要问完才交付(BUG-751) + assert.equal(decision.canOfferRange, false); + assert.equal(decision.sessionOutcome, "collect_evidence"); + assert.equal(decision.nextAction, "ask_fact_collection"); assert.equal(decision.canConfirmExactMinute, false); }); diff --git a/frontend/tests/rectification-precision-gate-20260916.test.ts b/frontend/tests/rectification-precision-gate-20260916.test.ts index 543c1392..909aebf2 100644 --- a/frontend/tests/rectification-precision-gate-20260916.test.ts +++ b/frontend/tests/rectification-precision-gate-20260916.test.ts @@ -6,6 +6,7 @@ import { decideRectification, mayDeliverOnPrecision, publicCanAdopt, + publicNextAction, sessionOutcomeAllowsDelivery, } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { computePrecisionGateMet } from "../src/lib/rectification-agentic/core/precision-gate.ts"; @@ -72,7 +73,28 @@ test("20 minute tied range keeps collecting with a guided question", () => { assert.equal(publicCanAdopt(decision), false); }); -test("8 minute range with a 5 point lead delivers", () => { +test("8 minute range with a 5 point lead delivers once every line is asked", () => { + const decision = decideRectification(input({ + candidateScores: [ + { time: "04:51", score: 20 }, + { time: "04:53", score: 15 }, + { time: "04:56", score: 10 }, + ], + inferenceCredibleRange: ["04:51", "04:59"], + precisionGateMet: true, + // 原值: guidedCollectExhausted: false + // 新值: guidedCollectExhausted: true + // 原因: D6——门槛只是必要条件,引导线没问完不出卡(BUG-751) + guidedCollectExhausted: true, + targetedCollectExhausted: true, + refreshExhausted: true, + })); + assert.equal(decision.canOfferRange, true); + assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true); + assert.equal(decision.precisionGateMet, true); +}); + +test("gate met but the targeted line is still open keeps asking", () => { const decision = decideRectification(input({ candidateScores: [ { time: "04:51", score: 20 }, @@ -82,11 +104,41 @@ test("8 minute range with a 5 point lead delivers", () => { inferenceCredibleRange: ["04:51", "04:59"], precisionGateMet: true, guidedCollectExhausted: false, - targetedCollectExhausted: true, + targetedCollectExhausted: false, refreshExhausted: true, })); - assert.equal(decision.canOfferRange, true); - assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true); + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.canOfferRange, false); + assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false); +}); + +test("empty guided pool with refresh untried does not deliver", () => { + const decision = decideRectification(input({ + candidateScores: [ + { time: "04:51", score: 20 }, + { time: "04:53", score: 15 }, + { time: "04:56", score: 10 }, + ], + inferenceCredibleRange: ["04:51", "04:59"], + precisionGateMet: true, + guidedCollectExhausted: true, + targetedCollectExhausted: true, + refreshExhausted: false, + })); + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.canOfferRange, false); + assert.equal( + mayDeliverOnPrecision({ + methodCoverageAll: true, + candidateScores: [], + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, + precisionGateMet: true, + guidedCollectExhausted: true, + refreshExhausted: false, + targetedCollectExhausted: true, + }), + false, + ); }); test("8 minute exact tie keeps collecting", () => { @@ -103,13 +155,29 @@ test("8 minute exact tie keeps collecting", () => { assert.equal(decision.canOfferRange, false); }); -test("gate unmet but guided collect exhausted still delivers", () => { +test("every line asked but the gate unmet still delivers under the existing rules", () => { + // D2:题源都空了就是「用户确实补不出来」,卡片、采用按钮、三句正文不变, + // 也不加「未达门槛」标注。D6 只保证在还有题可问时不会提前出卡。 const decision = decideRectification(input({ precisionGateMet: false, guidedCollectExhausted: true, })); assert.equal(decision.canOfferRange, true); assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true); + // 门槛值照常上报,不参与出卡判断。 + assert.equal(decision.precisionGateMet, false); +}); + +test("the precision gate is reported on a collect exit too", () => { + const collecting = decideRectification(input({ + precisionGateMet: false, + guidedCollectExhausted: false, + })); + assert.equal(collecting.nextAction, "ask_fact_collection"); + assert.equal(collecting.precisionGateMet, false); + assert.equal(publicNextAction(collecting).precision_gate_met, false); + const helper = decideRectification(input()); + assert.equal(helper.precisionGateMet, null); }); test("userStopped delivers even when the precision gate is unmet", () => { diff --git a/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts b/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts index 950381b4..b6898d4c 100644 --- a/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts +++ b/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts @@ -878,7 +878,14 @@ test("T4: exhausted refresh and declined targeted collect titles the card 目前 userId: USER_ID, caseId: CASE_ID, }); - assert.match(idle.hostNarration ?? "", /目前范围|范围已经收到|能问的都问完了/); + // 原值: /目前范围|范围已经收到|能问的都问完了/ + // 新值: 追加 /现在还剩 .+ 里 \d+ 个候选/ + // 原因: persistNextInterviewIfIdle 自己重算决策,没有 options 里的门槛覆盖, + // 所以它写的是采集侧的区间旁白;禁词断言不变(BUG-751) + assert.match( + idle.hostNarration ?? "", + /目前范围|范围已经收到|能问的都问完了|现在还剩 .+ 里 \d+ 个候选/, + ); assert.doesNotMatch(idle.hostNarration ?? "", /这次给出|最终/); assert.doesNotMatch(idle.hostNarration ?? "", /平时做事|月宿性格/); }); diff --git a/frontend/tests/rectification-provisional-adopt.test.ts b/frontend/tests/rectification-provisional-adopt.test.ts index b8cd1ee0..1c157fae 100644 --- a/frontend/tests/rectification-provisional-adopt.test.ts +++ b/frontend/tests/rectification-provisional-adopt.test.ts @@ -360,15 +360,16 @@ function rpcDossier(decision: DecisionDossier, activeFocus: Record { +test("incident shape 1a: occupation coverage keeps collecting until every line is asked", () => { for (const occupationCovered of ["declined", "confirmed_note"] as const) { const { decision, overlaid } = publicFields(incidentDossier({ occupationCovered })); assert.equal(decision.separation.lead, 5, occupationCovered); assert.equal(decision.separation.sufficient, false, occupationCovered); - // 旧:职业覆盖即可公开采用。新:财务等带年份采集未问完,公开 can_adopt 保持关闭。 - // 更新:训练门开后不再轮转采集,公开可交付(BUG-648) - assert.equal(overlaid.can_adopt, true, occupationCovered); - assert.equal(overlaid.session_outcome, "adopt_representative", occupationCovered); + // 原值: can_adopt=true / session_outcome=adopt_representative + // 新值: can_adopt=false / session_outcome=collect_evidence + // 原因: D5 让七条线全部轮到 + D6「所有线问完才交付」;财务等线还没问(BUG-751) + assert.equal(overlaid.can_adopt, false, occupationCovered); + assert.equal(overlaid.session_outcome, "collect_evidence", occupationCovered); assert.equal(overlaid.can_confirm_exact_minute, false, occupationCovered); assert.equal(decision.canConfirmExactMinute, false, occupationCovered); } @@ -384,8 +385,11 @@ test("incident shape 1b: exact-minute confirm stays closed on the accident case" test("incident shape 1c: uncovered occupation still collects remaining dated events", async () => { const dossier = incidentDossier({ occupationCovered: false }); const { decision, overlaid } = publicFields(dossier); - assert.equal(overlaid.can_adopt, true); - assert.equal(overlaid.session_outcome, "adopt_representative"); + // 原值: can_adopt=true / session_outcome=adopt_representative + // 新值: can_adopt=false / session_outcome=collect_evidence + // 原因: 同 1a——D5 + D6(BUG-751) + assert.equal(overlaid.can_adopt, false); + assert.equal(overlaid.session_outcome, "collect_evidence"); assert.equal(overlaid.can_confirm_exact_minute, false); let writtenDomain: string | null = null; @@ -450,13 +454,19 @@ test("incident shape 1c: uncovered occupation still collects remaining dated eve can_adopt: overlaid.can_adopt, representativeTime: overlaid.representative_time, decisionReceipt: dossier.latestResult?.decisionReceipt ?? null, - })), true); + // 原值: true + // 新值: false + // 原因: D6——线没问完不渲染可点卡(BUG-751) + })), false); }); test("after-choice persist collects remaining dated events after family denial", async () => { const dossier = incidentDossier({ occupationCovered: false }); const decision = decideFromDossier(dossier); - assert.equal(decision.canAdopt, true); + // 原值: canAdopt=true + // 新值: canAdopt=false + // 原因: D6——线没问完时交付能力位按 waitToNarrow 收起(BUG-751) + assert.equal(decision.canAdopt, false); const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => rpcDossier(dossier), @@ -577,10 +587,11 @@ test("incident shape 4: exact-minute confirm stays fail-closed even when local g test("delivery B1: GET overlay after occupation coverage renders representative cards", () => { const dossier = incidentDossier({ occupationCovered: "declined" }); const { decision, overlaid } = publicFields(dossier); - // 旧:职业拒答即可出可点卡。新:财务口述未问完只给只读区间。 - // 更新:训练门开后公开交付(BUG-648) - assert.equal(decision.sessionOutcome, "adopt_representative"); - assert.notEqual(decision.nextAction, "ask_fact_collection"); + // 原值: sessionOutcome=adopt_representative / nextAction!=ask_fact_collection + // 新值: sessionOutcome=collect_evidence / nextAction=ask_fact_collection + // 原因: D5 + D6——职业拒答只关一条线,其余线仍要问(BUG-751) + assert.equal(decision.sessionOutcome, "collect_evidence"); + assert.equal(decision.nextAction, "ask_fact_collection"); const parsed = parseRectificationCandidateResult({ resultId: dossier.latestResult?.resultId, candidates: overlaid.candidates, @@ -592,10 +603,10 @@ test("delivery B1: GET overlay after occupation coverage renders representative decisionReceipt: dossier.latestResult?.decisionReceipt ?? null, }); assert.ok(parsed); - // 原值: 财务未问完,不渲染可点卡 - // 新值: 训练门开后公开代表性卡;确认门仍关 - // 原因: BUG-648 - assert.equal(canRenderRectificationSelectionCards(parsed), true); + // 原值: 渲染可点卡(BUG-648 训练门开即公开) + // 新值: 不渲染可点卡 + // 原因: D6——线没问完不出交付卡(BUG-751) + assert.equal(canRenderRectificationSelectionCards(parsed), false); assert.deepEqual(parsed.candidates.map((item) => item.time), ["05:00", "05:07", "04:53"]); assert.equal(parsed.representativeTime, "05:00"); }); @@ -609,7 +620,10 @@ test("delivery B2: remaining dated collect stays in front of the adopt early-exi closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: decision.sessionOutcome, }); - assert.notEqual(plan.session_outcome, "collect_evidence"); + // 原值: plan.session_outcome != collect_evidence + // 新值: plan.session_outcome == collect_evidence + // 原因: D6——采集线未问完,计划层保持采集(BUG-751) + assert.equal(plan.session_outcome, "collect_evidence"); assert.notEqual(plan.next_followup?.domain, "finance"); const action = buildNextUserAction({ scorableCount: 5, @@ -620,10 +634,10 @@ test("delivery B2: remaining dated collect stays in front of the adopt early-exi nextFollowup: plan.next_followup, workingTime: "05:00", }); - // 原值: 财务口述挡在 adopt 前 - // 新值: 训练门开后走代表性交付 - // 原因: BUG-648 - assert.equal(action.id, "adopt_representative"); + // 原值: adopt_representative(BUG-648 训练门开即交付) + // 新值: ask_method_followup + // 原因: D6——采集线没问完,下一动作仍是问(BUG-751) + assert.equal(action.id, "ask_method_followup"); const accounting = fakeAccounting({ ...receiptHandlers, @@ -661,8 +675,11 @@ test("delivery B2: remaining dated collect stays in front of the adopt early-exi test("delivery B3: accept projection stays open from engine receipt, not exact-minute confirm", () => { const { decision, overlaid } = publicFields(incidentDossier({ occupationCovered: "declined" })); - assert.equal(decision.selectionAllowed, true); - assert.equal(overlaid.selection_allowed, true); + // 原值: selectionAllowed / overlaid.selection_allowed = true + // 新值: false + // 原因: D6——线没问完时不开放选择(BUG-751) + assert.equal(decision.selectionAllowed, false); + assert.equal(overlaid.selection_allowed, false); assert.equal(overlaid.can_confirm_exact_minute, false); assert.equal(decision.canConfirmExactMinute, false); }); diff --git a/frontend/tests/rectification-range-offer-deadend.test.ts b/frontend/tests/rectification-range-offer-deadend.test.ts index e0315713..a4453d28 100644 --- a/frontend/tests/rectification-range-offer-deadend.test.ts +++ b/frontend/tests/rectification-range-offer-deadend.test.ts @@ -430,7 +430,8 @@ test("skill version is 10.0.26 after the targeted-collect-cards bump", () => { // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28"); }); test("pre-fix dual-exit constant is gone; range narration carries numbers and the disclaimer", () => { @@ -508,14 +509,15 @@ test("live remaining probes with zero active split drop and open provisional ado const dossier = liveDossier(remaining); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); assert.equal(decision.probe, null); - // 原值: ask_fact_collection / collect_evidence - // 新值: 训练门已开且剩余探针无拆分 → 交付 - // 原因: 不再用领域轮转填剩余采集(BUG-648) - assert.equal(decision.nextAction, "ready_to_adopt"); - assert.notEqual(decision.sessionOutcome, "collect_evidence"); + // 原值: ready_to_adopt / sessionOutcome != collect_evidence / selectionAllowed=true + // 新值: ask_fact_collection / collect_evidence / selectionAllowed=false + // 原因: D5 + D6——剩余探针无拆分只说明没有点选题,定向线还没问完; + // 本题真正要的「零拆分探针被 drop」由下面两条仍然保证(BUG-751) + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.sessionOutcome, "collect_evidence"); assert.equal(isNonConvergingRangeOffer(decision), false); - assert.equal(decision.canAdopt, true); - assert.equal(decision.selectionAllowed, true); + assert.equal(decision.canAdopt, false); + assert.equal(decision.selectionAllowed, false); assert.equal(decision.canConfirmExactMinute, false); assert.deepEqual(decision.credibleRange, ["04:47", "04:53"]); assert.equal(decision.representativeTime, "04:51"); @@ -530,7 +532,10 @@ test("live remaining probes with zero active split drop and open provisional ado test("adoptable range still collects remaining dated events after family denial", async () => { const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION]); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); - assert.equal(decision.canAdopt, true); + // 原值: canAdopt=true + // 新值: canAdopt=false + // 原因: D6——线没问完时交付能力位收起(BUG-751) + assert.equal(decision.canAdopt, false); assert.equal(isNonConvergingRangeOffer(decision), false); const card = projectRectificationChoiceCard({ evidence: dossier.evidence, diff --git a/frontend/tests/rectification-replay-20260911.test.ts b/frontend/tests/rectification-replay-20260911.test.ts index 9626aa9d..e7f56239 100644 --- a/frontend/tests/rectification-replay-20260911.test.ts +++ b/frontend/tests/rectification-replay-20260911.test.ts @@ -463,7 +463,8 @@ test("skill version is 10.0.26", () => { // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28"); }); test("two education events do not spawn birth-year reverse questions", () => { diff --git a/frontend/tests/rectification-spoken-collect.test.ts b/frontend/tests/rectification-spoken-collect.test.ts index a8010a2c..16b43bdd 100644 --- a/frontend/tests/rectification-spoken-collect.test.ts +++ b/frontend/tests/rectification-spoken-collect.test.ts @@ -100,7 +100,8 @@ test("skill version is 10.0.26 after the targeted-collect-cards bump", () => { // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28"); }); test("cases current_question remains the submit contract, not a visual slot", () => { diff --git a/frontend/tests/rectification-superseded-focus.test.ts b/frontend/tests/rectification-superseded-focus.test.ts index b9b346d9..ecfbe71f 100644 --- a/frontend/tests/rectification-superseded-focus.test.ts +++ b/frontend/tests/rectification-superseded-focus.test.ts @@ -290,7 +290,11 @@ test("duplicate_focus on the last collect ask does not emit delivery copy", asyn }); assert.doesNotMatch(idle.hostNarration ?? "", /领域不限|做不了|材料不够/); const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus"); - assert.notEqual(setFocus?.args.p_target_domain, "relationship"); + // 原值: p_target_domain 不得是 relationship / finance + // 新值: 写的是定向感情线(本夹具就叫 relationshipOpenDossier,这条线是开着的) + // 原因: D5——七条线全部轮到,感情线仍未覆盖就该被问;本题真正的断言是 + // 上一行「不得播交付文案」(BUG-751) + assert.match(String(setFocus?.args.p_question_id ?? ""), /^collect:(targeted|guided):/); assert.notEqual(setFocus?.args.p_target_domain, "finance"); }); diff --git a/frontend/tests/rectification-surface-contract.test.ts b/frontend/tests/rectification-surface-contract.test.ts index 81ebac1a..ea7b7036 100644 --- a/frontend/tests/rectification-surface-contract.test.ts +++ b/frontend/tests/rectification-surface-contract.test.ts @@ -39,7 +39,10 @@ test("the rectification surface is revealed once: Case hydration precedes the sw // 新值:initialSnapshot: rectificationSnapshot 写在 hook 的 panel // 原因:snapshot 只服务子树,随 panel 下沉 assert.match(page, /initialSnapshot: rectificationSnapshot/); - assert.match(page, /declaredTime=\{rectificationDeclaredTime\}/); + // 原值: /declaredTime=\{rectificationDeclaredTime\}/ + // 新值: /\{\.\.\.rectificationDeclaration\}/ + // 原因: F7——declaredTime 与 birthDate 合成一个对象展开,page.tsx 不增长(BUG-751) + assert.match(page, /\{\.\.\.rectificationDeclaration\}/); assert.match(wrapper, /initialSnapshot: RectificationCaseSnapshotPayload \| null;/); assert.match(chat, /useState\(initialSnapshot !== null\)/); // Later turns fill an empty transcript by render-time adjustment from the previous prop, not an effect. diff --git a/frontend/tests/rectification-targeted-collect-cards-20260913.test.ts b/frontend/tests/rectification-targeted-collect-cards-20260913.test.ts index 8d945ba0..06f4dc3d 100644 --- a/frontend/tests/rectification-targeted-collect-cards-20260913.test.ts +++ b/frontend/tests/rectification-targeted-collect-cards-20260913.test.ts @@ -6,6 +6,7 @@ import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types. import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; import { SPLIT_ENDPOINT_PHRASE, + TARGETED_EXISTENCE_PROMPT, pendingTargetedYearDomain, targetedCollectExhausted, targetedCollectPool, @@ -55,7 +56,10 @@ test("A resolves existence then asks the year; B/C close only that line", () => const first = targetedCollectFollowup(LAYERS, EVIDENCE, [], ["04:48", "05:07"], 4); assert.equal(first?.collection_key, "collect:targeted:relationship"); assert.equal(first?.choice_frame?.scoring, false); - assert.equal(first?.choice_frame?.prompt, "结过婚或订过婚吗?"); + // 原值: "结过婚或订过婚吗?" + // 新值: TARGETED_EXISTENCE_PROMPT.relationship(问整个领域、任何时间) + // 原因: D5/T4b——窄题干答「没有」不得当成整条线没有(BUG-741 / BUG-751) + assert.equal(first?.choice_frame?.prompt, TARGETED_EXISTENCE_PROMPT.relationship); assert.match(first?.spoken_prompt ?? "", /现在还剩 04:48–05:07 里 4 个候选/); assert.doesNotMatch(first?.spoken_prompt ?? "", SPLIT_ENDPOINT_PHRASE); @@ -82,11 +86,24 @@ test("A resolves existence then asks the year; B/C close only that line", () => 4, ); assert.equal(afterNo[0]?.domain, "relocation"); + // 原值: 拒答 4 条线即耗尽 + // 新值: 七条线都要拒答才耗尽 + // 原因: D5——remainingTargetedDomains 不再按分盘层剔除,七个 CollectKind + // 按 COLLECT_KIND_ORDER 全部轮到(BUG-751) assert.equal(targetedCollectExhausted(LAYERS, EVIDENCE, [ declined("relationship"), declined("relocation"), declined("family"), declined("finance"), + ], ["04:48", "05:07"], 4), false); + assert.equal(targetedCollectExhausted(LAYERS, EVIDENCE, [ + declined("education"), + declined("career"), + declined("relocation"), + declined("relationship"), + declined("family"), + declined("finance"), + declined("health_pressure"), ], ["04:48", "05:07"], 4), true); }); @@ -205,12 +222,12 @@ test("personality D9/D10 stay out of the scoring pool until the range-card opt-i }, tieBreakAvailable: tieBreakPersonalityAvailable(planInput), }); - // 原值: 拒答后收口「能问的都问完了」 - // 新值: 仍不列出已关闭的线;收口改为不限领域的补充邀请 - // 原因: BUG-687 不得列出已关闭的线;BUG-689 采集线关闭不等于校正结束 - assert.match(delivery.narrow_hint ?? "", /不限领域/); + // 原值: narrow_hint 含「不限领域」的自由文本邀请 + // 新值: 引导句「现在还剩 HH:MM–HH:MM 里 N 个候选…」,且禁自由文本邀请 + // 原因: D3——门槛未达时系统点名逐题问,不再邀请随便打字(BUG-740 / BUG-751) + assert.doesNotMatch(delivery.narrow_hint ?? "", /不限领域/); assert.doesNotMatch(delivery.narrow_hint ?? "", /问完了/); - assert.doesNotMatch(delivery.narrow_hint ?? "", /搬家|收入/); + assert.match(delivery.narrow_hint ?? "", /现在还剩 .+ 里 \d+ 个候选/); assert.doesNotMatch(delivery.narrow_hint ?? "", SPLIT_ENDPOINT_PHRASE); assert.equal(delivery.tie_break_available, true); const incompleteScan = rangeDeliveryForSnapshot({ diff --git a/frontend/tests/rectification-tie-break-entry-20260913.test.ts b/frontend/tests/rectification-tie-break-entry-20260913.test.ts index c4dbd5fc..ecaccc85 100644 --- a/frontend/tests/rectification-tie-break-entry-20260913.test.ts +++ b/frontend/tests/rectification-tie-break-entry-20260913.test.ts @@ -196,7 +196,8 @@ test("click path does not surface 409 copy; round continue is opt-in only", () = test("Skill 10.0.26 lists the fourth targeted-collect skip option", () => { const skill = readFileSync(new URL("../../skills/jyotish-birth-time-rectification/SKILL.md", import.meta.url), "utf8"); const strategy = readFileSync(new URL("../../skills/jyotish-birth-time-rectification/references/conversation-strategy.md", import.meta.url), "utf8"); - assert.match(skill, /^version: 10\.0\.27$/m); + // 原值: /^version: 10\.0\.27$/ / 新值: /^version: 10\.0\.28$/ / 原因: D6 改写出卡句后 Skill bump + assert.match(skill, /^version: 10\.0\.28$/m); assert.match(skill, /已拒绝(没有发生过 \/ 这类事都没有过)的目标不得换词重问;跳过的按服务器计划最多重问一次/); assert.match(strategy, /跳过的线按服务器计划最多换一种问法再问一次/); }); diff --git a/frontend/tests/rectification-v9-agent.test.ts b/frontend/tests/rectification-v9-agent.test.ts index a5fcb49d..342ebd0c 100644 --- a/frontend/tests/rectification-v9-agent.test.ts +++ b/frontend/tests/rectification-v9-agent.test.ts @@ -103,7 +103,8 @@ test("agent pins the dedicated rectification skill and its fixed version", () => // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.27")); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.28")); assert.notEqual(RECTIFICATION_V9_SKILL_PATH, RECTIFICATION_V9_PACKAGE_PATH); assert.equal(realpathSync(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_PACKAGE_PATH); assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification"); @@ -112,7 +113,8 @@ test("agent pins the dedicated rectification skill and its fixed version", () => // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28"); }); test("step budgets are bounded per action with a hard ceiling", () => { diff --git a/frontend/tests/rectification-v9-contracts.test.ts b/frontend/tests/rectification-v9-contracts.test.ts index 915b20ef..7f960b5a 100644 --- a/frontend/tests/rectification-v9-contracts.test.ts +++ b/frontend/tests/rectification-v9-contracts.test.ts @@ -100,14 +100,16 @@ test("the active rectification skill pins the v10 identity and lives in the righ // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28"); assert.match(skill, /^---\nname: jyotish-birth-time-rectification/m); // 原值: "10.0.25" // 原值: "10.0.26" // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.match(skill, /^version: 10\.0\.27$/m); + // 原值: /^version: 10\.0\.27$/ / 新值: /^version: 10\.0\.28$/ / 原因: D6 改写出卡句后 Skill bump + assert.match(skill, /^version: 10\.0\.28$/m); assert.match(skill, /至多一个主问题且唯一来源:[\s\S]*不得自行提出、复述、改写或预告问题/); for (const reference of references) { const content = readFileSync(`${skillDirectory}/references/${reference}`, "utf8"); diff --git a/frontend/tests/rectification-v9-entry-routing.test.ts b/frontend/tests/rectification-v9-entry-routing.test.ts index ecd468da..afd4f08f 100644 --- a/frontend/tests/rectification-v9-entry-routing.test.ts +++ b/frontend/tests/rectification-v9-entry-routing.test.ts @@ -210,7 +210,8 @@ test("open RPC passes the pinned skill and server-derived baseline only", async status: "draft", should_start_opening: true, // 原值: "10.0.25";新值: "10.0.26";原因: BUG-668 新案绑定现行 Skill - skill_version: "10.0.27", + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + skill_version: "10.0.28", }; } return null; @@ -253,7 +254,8 @@ test("open RPC passes the pinned skill and server-derived baseline only", async // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(response.skillVersion, "10.0.27"); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.equal(response.skillVersion, "10.0.28"); const openCall = accounting.calls.find((call) => call.fn === "open_agentic_rectification_case_v2"); assert.ok(openCall); assert.equal(openCall.args.p_skill_name, "jyotish-birth-time-rectification"); @@ -262,7 +264,8 @@ test("open RPC passes the pinned skill and server-derived baseline only", async // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(openCall.args.p_skill_version, "10.0.27"); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.equal(openCall.args.p_skill_version, "10.0.28"); assert.equal(openCall.args.p_user_id, "user-1"); // The server derives the baseline; the request never carries it from the browser. assert.equal("birth_date" in openCall.args, false); diff --git a/frontend/tests/rectification-window-cluster-cap-20260909.test.ts b/frontend/tests/rectification-window-cluster-cap-20260909.test.ts index 6f5e68cd..83a9eb15 100644 --- a/frontend/tests/rectification-window-cluster-cap-20260909.test.ts +++ b/frontend/tests/rectification-window-cluster-cap-20260909.test.ts @@ -91,5 +91,6 @@ test("agent body cannot verbally accept a spoken birth window", () => { // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28"); }); diff --git a/frontend/tests/rectification-yearless-probe-downgrade-20260909.test.ts b/frontend/tests/rectification-yearless-probe-downgrade-20260909.test.ts index f27317a1..bccf021c 100644 --- a/frontend/tests/rectification-yearless-probe-downgrade-20260909.test.ts +++ b/frontend/tests/rectification-yearless-probe-downgrade-20260909.test.ts @@ -166,7 +166,8 @@ test("SCORE_DELTA stays ±2/±1 and yearless weight is half", () => { // 新值: "10.0.27" // 原因: 出卡精度门槛 + 引导式补经历 // 原因: BUG-668 定向补事第四选项写进 Skill - assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.27"); + // 原值: "10.0.27" / 新值: "10.0.28" / 原因: D6 改写出卡句后 Skill bump + assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.28"); }); test("D9 answer B moves scores by ±1 and does not count toward elimination", () => { diff --git a/frontend/tests/skill-registry.test.ts b/frontend/tests/skill-registry.test.ts index cf7c3c4a..b0c943c5 100644 --- a/frontend/tests/skill-registry.test.ts +++ b/frontend/tests/skill-registry.test.ts @@ -85,8 +85,11 @@ test("checked-in registry verifies hashed product packages and leaves consult on [ { name: "jyotish-birth-time-rectification", - version: "10.0.27", - sha256: "7e5417419ffad9bb34b78318f541484821bb22476655092047006b3a05aff020", + // 原值: 10.0.27 / 7e541741…f020 + // 新值: 10.0.28 / f0bb8295…cffe + // 原因: D6 改写 §流程出卡句(门槛只是必要条件),Skill 随之 bump + version: "10.0.28", + sha256: "f0bb8295f3ba029992fad141e90a56931bf6a0867c827ff4e37a24452e87cffe", }, { name: "jyotish-personal-report", diff --git a/scripts/rectification/event_probes.py b/scripts/rectification/event_probes.py index 07f451d1..671717ee 100644 --- a/scripts/rectification/event_probes.py +++ b/scripts/rectification/event_probes.py @@ -1793,6 +1793,8 @@ _GUIDED_TRACK_DOMAIN = { "nara:d9": "relationship", "nara:d10": "career", } +#: Windows whose track carries no domain are asked open (BUG-749). +GUIDED_ANY_DOMAIN = "any" def _guided_track_starts( @@ -1883,20 +1885,6 @@ def guided_collect_windows( } now = today or date.today() lo, hi = birth_year + 5, min(now.year, birth_year + 80) - remaining_layers = _differing_layers(work) - fallback = [ - domain - for domain in _probe_domains( - remaining_layers, - [item for item in (request.get("events") or []) if isinstance(item, dict)], - d1_differs="d1" in remaining_layers, - ) - if domain not in declined - ] - if not fallback: - fallback = [domain for domain in GUIDED_DOMAIN_ORDER if domain not in declined] - if not fallback: - return [] vim_cache: dict[tuple[Any, ...], list[date]] = {} narayana_cache: dict[tuple[Any, ...], list[date] | None] = {} starts_by_time: dict[str, dict[str, list[date]]] = {} @@ -1916,7 +1904,6 @@ def guided_collect_windows( ) rows: list[dict[str, Any]] = [] seen: set[tuple[int, int, int, str]] = set() - unlayered_index = 0 for left, right in _representative_pairs(work): left_time = str(_context_time(left) or "") right_time = str(_context_time(right) or "") @@ -1944,14 +1931,16 @@ def guided_collect_windows( right_n = len(gathered) - left_n if left_n < 1 or right_n < 1: continue + # BUG-749: only the D9 / D10 Narayana tracks carry a domain. + # Vimshottari and natal Narayana boundaries say nothing about + # which area of life moved, so the window is asked open + # ("any") instead of being round-robined onto one domain. track_domain = _GUIDED_TRACK_DOMAIN.get(track) - if track_domain and track_domain not in declined: - domain = track_domain - else: - domain = fallback[unlayered_index % len(fallback)] - unlayered_index += 1 - if domain in declined: - continue + domain = ( + track_domain + if track_domain and track_domain not in declined + else GUIDED_ANY_DOMAIN + ) for year, month_lo, month_hi in _guided_year_windows(gathered): if year < lo or year > hi: continue diff --git a/scripts/research/guided_collect_holdout_replay.py b/scripts/research/guided_collect_holdout_replay.py index 38bbe246..98a992e5 100644 --- a/scripts/research/guided_collect_holdout_replay.py +++ b/scripts/research/guided_collect_holdout_replay.py @@ -1,6 +1,12 @@ #!/usr/bin/env python3 """Offline T2 replay: after six probes, inject guided_collect_windows events. +F5 (2026-09-16): the injected event now lands on the **true** candidate's own +boundary date for that window's track and index, so the replay measures what a +user who really lived through that window would report. A second, opposite +pass injects the boundary of the candidate furthest from the true minute, as a +control. Neither pass is a merge gate; both numbers go in the progress note. + Does not change production defaults. Writes a compact JSON summary for the progress note. Not a merge gate. """ @@ -29,6 +35,15 @@ from scripts.active_rectification_event_engine import ( # noqa: E402 compute_candidate_static_contexts, ) from scripts.rectification.event_probes import ( # noqa: E402 + GUIDED_ANY_DOMAIN, + GUIDED_COLLECT_LIMIT, + _birth_year, + _context_time, + _guided_track_starts, + _guided_year_windows, + _remaining_contexts, + _scoreable, + _static_contexts, discriminating_event_probes, guided_collect_windows, ) @@ -68,13 +83,23 @@ def load_cases() -> list[dict[str, Any]]: return list(payload.get("cases") or []) -def synthetic_event(window: dict[str, Any], index: int) -> dict[str, Any]: - year = int(window["year"]) - month = int(window["month_lo"]) - last = monthrange(year, month)[1] - domain = str(window.get("domain") or "career") +#: Windows are asked open now (BUG-749); the replay still needs one concrete +#: domain to write into the ledger, so an open window uses this one. +DEFAULT_OPEN_DOMAIN = "career" + + +def synthetic_event( + window: dict[str, Any], + index: int, + *, + when: date, +) -> dict[str, Any]: + """One month-precision event on `when`, labelled with the window's domain.""" + last = monthrange(when.year, when.month)[1] + raw_domain = str(window.get("domain") or "") + domain = DEFAULT_OPEN_DOMAIN if raw_domain in {"", GUIDED_ANY_DOMAIN} else raw_domain kind = KIND_BY_DOMAIN.get(domain, "career_change") - stamp = f"{year:04d}-{month:02d}" + stamp = f"{when.year:04d}-{when.month:02d}" return { "id": str(uuid5(NAMESPACE_URL, f"guided-collect:{index}:{domain}:{stamp}")), "domain": domain, @@ -86,6 +111,74 @@ def synthetic_event(window: dict[str, Any], index: int) -> dict[str, Any]: } +def _minutes(value: str) -> int: + hour, minute = value.split(":")[:2] + return int(hour) * 60 + int(minute) + + +def window_boundary_dates( + request: dict[str, Any], + built: dict[str, Any], + *, + candidate_times: Sequence[str], + today: date, +) -> dict[tuple[int, int, int], dict[str, date]]: + """Map each guided window to the per-candidate boundary date behind it. + + Mirrors `guided_collect_windows`'s own loop so the (track, index) pairing + is the engine's, not a guess: for every window key (year, month_lo, + month_hi) it returns {candidate_time: that candidate's boundary date}. + """ + birth_date = str(request.get("birth_date") or "").strip() + birth_year = _birth_year(birth_date) + if birth_year is None: + return {} + remaining = _remaining_contexts(built, candidate_times) + if len(remaining) < 2: + remaining = _static_contexts(built) + work = [item for item in remaining if _scoreable(item)] + if len(work) < 2: + return {} + lo, hi = birth_year + 5, min(today.year, birth_year + 80) + vim_cache: dict[tuple[Any, ...], list[date]] = {} + narayana_cache: dict[tuple[Any, ...], list[date] | None] = {} + starts_by_time: dict[str, dict[str, list[date]]] = {} + for context in work: + stamp = _context_time(context) + if not stamp: + continue + starts_by_time[stamp] = _guided_track_starts( + context, + birth_date=birth_date, + lo=lo, + hi=hi, + include_pratyantar=True, + varga_narayana=True, + vim_cache=vim_cache, + narayana_cache=narayana_cache, + ) + out: dict[tuple[int, int, int], dict[str, date]] = {} + for track in ("vim", "nara", "nara:d9", "nara:d10"): + lengths = [len(starts_by_time.get(str(_context_time(item)) or "", {}).get(track) or []) for item in work] + if not lengths or min(lengths) == 0: + continue + for index in range(min(lengths)): + per_time: dict[str, date] = {} + for context in work: + stamp = _context_time(context) + dates = (starts_by_time.get(stamp or "") or {}).get(track) or [] + if stamp and index < len(dates): + per_time[stamp] = dates[index] + gathered = list(per_time.values()) + if len(gathered) < 2 or min(gathered) == max(gathered): + continue + for year, month_lo, month_hi in _guided_year_windows(gathered): + if year < lo or year > hi: + continue + out.setdefault((year, month_lo, month_hi), per_time) + return out + + def posterior_state( *, rows: Sequence[dict[str, Any]], @@ -191,6 +284,7 @@ def remaining_times(state: dict[str, Any]) -> list[str]: def evaluate_case( case: dict[str, Any], radius: int, + direction: str = "truth", ) -> dict[str, Any]: true_time = str(case["birth"]["time"])[:5] request = scoring_request_for(case, radius) @@ -215,6 +309,7 @@ def evaluate_case( return { "case_id": case.get("case_id"), "radius": radius, + "direction": direction, "events_needed": 0, "windows": 0, "met": True, @@ -227,10 +322,33 @@ def evaluate_case( candidate_times=remaining or times, today=TODAY, ) + boundaries = window_boundary_dates( + request, + built, + candidate_times=remaining or times, + today=TODAY, + ) + pool = remaining or times + if direction == "truth": + source_time = true_time + else: + source_time = max( + pool, + key=lambda stamp: abs(_minutes(stamp) - _minutes(true_time)), + ) if pool else true_time extras: list[dict[str, Any]] = [] last_gate = gate + injected_from: list[str] = [] for index, window in enumerate(windows): - extras.append(synthetic_event(window, index)) + key = (int(window["year"]), int(window["month_lo"]), int(window["month_hi"])) + per_time = boundaries.get(key) or {} + when = per_time.get(source_time) + if when is None and per_time: + when = per_time[min(per_time, key=lambda stamp: abs(_minutes(stamp) - _minutes(source_time)))] + if when is None: + when = date(int(window["year"]), int(window["month_lo"]), 1) + injected_from.append(f"{source_time}:{when.isoformat()}") + extras.append(synthetic_event(window, index, when=when)) injected = {**request, "events": list(request["events"]) + extras} rebuilt = build_event_contribution_matrix( scoreable_request(injected), @@ -248,34 +366,55 @@ def evaluate_case( return { "case_id": case.get("case_id"), "radius": radius, + "direction": direction, "events_needed": len(extras), "windows": len(windows), "met": True, "after_six": gate, "final": last_gate, + "injected": injected_from, } return { "case_id": case.get("case_id"), "radius": radius, + "direction": direction, "events_needed": None, "windows": len(windows), "met": False, "after_six": gate, "final": last_gate, + "injected": injected_from, } -def summarize(rows: Sequence[dict[str, Any]], radius: int) -> dict[str, Any]: - subset = [row for row in rows if row.get("radius") == radius and not row.get("error")] +def summarize(rows: Sequence[dict[str, Any]], radius: int, direction: str) -> dict[str, Any]: + subset = [ + row for row in rows + if row.get("radius") == radius and row.get("direction") == direction and not row.get("error") + ] needed = [int(row["events_needed"]) for row in subset if row.get("events_needed") is not None] + # Cases the six probes had not already settled: the only ones the guided + # windows can be credited for. + from_guided = [ + int(row["events_needed"]) for row in subset + if row.get("events_needed") not in (None, 0) + ] met = sum(1 for row in subset if row.get("met")) return { "radius": radius, + "direction": direction, "n": len(subset), "met": met, + "met_after_six_only": sum(1 for row in subset if row.get("events_needed") == 0), + "met_via_guided": len(from_guided), "median_events_to_gate": statistics.median(needed) if needed else None, + "median_events_via_guided": statistics.median(from_guided) if from_guided else None, + "median_windows": statistics.median([int(row.get("windows") or 0) for row in subset]) if subset else None, "unmet": sum(1 for row in subset if not row.get("met")), - "errors": sum(1 for row in rows if row.get("radius") == radius and row.get("error")), + "errors": sum( + 1 for row in rows + if row.get("radius") == radius and row.get("direction") == direction and row.get("error") + ), } @@ -283,9 +422,11 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--limit", type=int, default=0) parser.add_argument("--radii", default="10,30,60") + parser.add_argument("--directions", default="truth,opposite") parser.add_argument("--json-out", default=str(REPORT_JSON)) args = parser.parse_args() radii = tuple(int(item) for item in str(args.radii).split(",") if item.strip()) + directions = tuple(item.strip() for item in str(args.directions).split(",") if item.strip()) cases = load_cases() if args.limit: cases = cases[: args.limit] @@ -293,30 +434,38 @@ def main() -> int: rows: list[dict[str, Any]] = [] for case in cases: for radius in radii: - label = f"{case.get('case_id')} ±{radius}" - try: - result = evaluate_case(case, radius) - rows.append(result) - print( - f"{label} needed={result.get('events_needed')} " - f"met={result.get('met')} windows={result.get('windows')}", - flush=True, - ) - except Exception as exc: # noqa: BLE001 - rows.append({ - "case_id": case.get("case_id"), - "radius": radius, - "error": f"{type(exc).__name__}: {exc}", - "trace": traceback.format_exc(limit=8), - "met": False, - "events_needed": None, - }) - print(f"{label} ERROR {type(exc).__name__}: {exc}", flush=True) - summaries = [summarize(rows, radius) for radius in radii] + for direction in directions: + label = f"{case.get('case_id')} ±{radius} {direction}" + try: + result = evaluate_case(case, radius, direction) + rows.append(result) + print( + f"{label} needed={result.get('events_needed')} " + f"met={result.get('met')} windows={result.get('windows')}", + flush=True, + ) + except Exception as exc: # noqa: BLE001 + rows.append({ + "case_id": case.get("case_id"), + "radius": radius, + "direction": direction, + "error": f"{type(exc).__name__}: {exc}", + "trace": traceback.format_exc(limit=8), + "met": False, + "events_needed": None, + }) + print(f"{label} ERROR {type(exc).__name__}: {exc}", flush=True) + summaries = [summarize(rows, radius, direction) for radius in radii for direction in directions] payload = { "generated_at": TODAY.isoformat(), "holdout": str(HOLDOUT.relative_to(ROOT)).replace("\\", "/"), "ask_count": ASK_COUNT, + "guided_collect_limit": GUIDED_COLLECT_LIMIT, + "injection": ( + "truth = the true candidate's own boundary date for that window's " + "track and index; opposite = the boundary of the remaining " + "candidate furthest from the true minute" + ), "delivery_max_width_minutes": DELIVERY_MAX_WIDTH_MINUTES, "tie_percent": RANGE_DELIVERY_TIE_PERCENT, "elapsed_s": round(time.perf_counter() - started, 1), diff --git a/skills/jyotish-birth-time-rectification/SKILL.md b/skills/jyotish-birth-time-rectification/SKILL.md index ccebd0a6..d7ef7250 100644 --- a/skills/jyotish-birth-time-rectification/SKILL.md +++ b/skills/jyotish-birth-time-rectification/SKILL.md @@ -1,6 +1,6 @@ --- name: jyotish-birth-time-rectification -version: 10.0.27 +version: 10.0.28 description: "生时校正专用 Skill(V10)。以服务器权威 Case、ConversationFocus 与 CaseConversationSummary 驱动低负担访谈;批量证据逐项判定,candidate / accepted / confirmed 严格分离,全部计算与持久化只走服务端工具。触发词:生时校正、出生时间校正、校正出生时间、rectification、birth time correction。" --- @@ -81,7 +81,7 @@ description: "生时校正专用 Skill(V10)。以服务器权威 Case、Conv `CaseConversationSummary` 是长会话的权威记忆,至少投影:confirmed evidence summary、pending revisions、active focus、declined/skipped topics、candidate divergence summary、missing evidence categories、`method_followup_plan`、last result policy。 - 选择下一动作、识别已确认事实、避免重复追问、理解候选差异与结果政策时,优先依据服务器提供的 `CaseConversationSummary` 与 `method_followup_plan`。 -- 不要按 `missing_evidence_categories` 轮询迁居。财务、健康与其他经历同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。下一问只跟 `method_followup_plan.next_followup`。收集按信息价值排序(邀请「还有吗」→ 用户年份锚定追问 → 无年份通用补问),问到训练门开;训练门开后先问带年月选择题。带年月池空时先按剩余候选刷新一批带年月题;仍无题则按 `guided_collect_windows` 逐条问(YYYY 年 M 到 M 月、哪一类事),再问跳过线一次,再问尚未覆盖的领域。引导题用选项 + 年/月选择器录入,不得邀请自由打字。时间点题答「没发生」只关那个时点,不关领域。出卡须 `precision_gate_met`(宽度 ≤10 分钟且前两名相对可能性差 >3 个百分点且不并列)或用户说「没有了 / 就这些」。题干写「现在还剩 HH:MM–HH:MM 里 N 个候选」,不得写「能把两端钟点分开」。性格题只作卡下可选入口「再答两道参考题微调排序」,不点不出。训练门关时只写精确缺口、保持开放,不出「做不了」。不得用生日推年份。已有带日期事件且存在 `discriminating_event_probes` 大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层,不要 offer。占问不挡出牌;职业挡出牌。外貌、体质、胎记或疤痕不得追问。收集经历用自然语言问一件带大概年份的事,set-focus 不要写 choice。只有 `next_followup` 带 `choice_frame`(冲突探针、定向补事「有没有」、候选已经分不开或采用后核对前事)时才写 A/B/C/D 点选卡;题干由你写成自然语言,时间范围、领域和语义目标以服务器探针为准,不得发明年份,不得改写时间范围;不要逐字复述服务器的事件家族标签,也不要把标签里的多个例子全堆进一句。结合最近对话只选一个用户最容易回答的口语入口,不要问两套盘哪个更像。正文不要复述选项。「先这样」由服务器补全。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,本轮零追问。`next_user_action.id=verify_adopted_time` 时本轮只核一件前事,不要 offer、不要看盘;A 写入并 compare,C 关闭该问,对不上可改选。`id=start_consultation` 时请用户用当前采用时间看盘。`deferred_followup` 留给用户以后再补,不得当成本轮问题。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问,不得 offer。 +- 不要按 `missing_evidence_categories` 轮询迁居。财务、健康与其他经历同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。下一问只跟 `method_followup_plan.next_followup`。收集按信息价值排序(邀请「还有吗」→ 用户年份锚定追问 → 无年份通用补问),问到训练门开;训练门开后先问带年月选择题。带年月池空时先按剩余候选刷新一批带年月题;仍无题则按 `guided_collect_windows` 逐条问(YYYY 年 M 到 M 月、哪一类事),再问跳过线一次,再问尚未覆盖的领域。引导题用选项 + 年/月选择器录入,不得邀请自由打字。时间点题答「没发生」只关那个时点,不关领域。所有线(含引导窗口题、跳过线重问、未覆盖领域题)问完后,或用户说「没有了 / 就这些」后,才交付目前范围;`precision_gate_met` 只上报,不改变出卡时机,门槛未达也不加标注。题干写「现在还剩 HH:MM–HH:MM 里 N 个候选」,不得写「能把两端钟点分开」。性格题只作卡下可选入口「再答两道参考题微调排序」,不点不出。训练门关时只写精确缺口、保持开放,不出「做不了」。不得用生日推年份。已有带日期事件且存在 `discriminating_event_probes` 大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层,不要 offer。占问不挡出牌;职业挡出牌。外貌、体质、胎记或疤痕不得追问。收集经历用自然语言问一件带大概年份的事,set-focus 不要写 choice。只有 `next_followup` 带 `choice_frame`(冲突探针、定向补事「有没有」、候选已经分不开或采用后核对前事)时才写 A/B/C/D 点选卡;题干由你写成自然语言,时间范围、领域和语义目标以服务器探针为准,不得发明年份,不得改写时间范围;不要逐字复述服务器的事件家族标签,也不要把标签里的多个例子全堆进一句。结合最近对话只选一个用户最容易回答的口语入口,不要问两套盘哪个更像。正文不要复述选项。「先这样」由服务器补全。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,本轮零追问。`next_user_action.id=verify_adopted_time` 时本轮只核一件前事,不要 offer、不要看盘;A 写入并 compare,C 关闭该问,对不上可改选。`id=start_consultation` 时请用户用当前采用时间看盘。`deferred_followup` 留给用户以后再补,不得当成本轮问题。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问,不得 offer。 - recent turns 只是有界的原文引用窗口,用于核对当前措辞、quote 和局部承接;不得把 recent turns 当作唯一记忆,也不得用截断历史覆盖 summary。 - summary 与 recent turns 看似冲突时,不自行裁决或默默改写事实:以服务器状态为准;需要用户确认时围绕 active focus 只澄清一个关键点。 - 超过长会话窗口后仍不得忘记已确认证据、pending revision、拒答主题或 active focus。 diff --git a/skills/jyotish-birth-time-rectification/versions/10.0.28/SKILL.md b/skills/jyotish-birth-time-rectification/versions/10.0.28/SKILL.md new file mode 100644 index 00000000..d7ef7250 --- /dev/null +++ b/skills/jyotish-birth-time-rectification/versions/10.0.28/SKILL.md @@ -0,0 +1,146 @@ +--- +name: jyotish-birth-time-rectification +version: 10.0.28 +description: "生时校正专用 Skill(V10)。以服务器权威 Case、ConversationFocus 与 CaseConversationSummary 驱动低负担访谈;批量证据逐项判定,candidate / accepted / confirmed 严格分离,全部计算与持久化只走服务端工具。触发词:生时校正、出生时间校正、校正出生时间、rectification、birth time correction。" +--- + +# Jyotish 生时校正(V10) + +## 1. 触发条件与方法学归属 + +本 Skill 只服务 `agentic_rectification_cases` 绑定的生时校正会话: + +- 服务端 Case 存在且 `skill_name = 'jyotish-birth-time-rectification'`。 +- 用户话题是出生时间 / 出生分钟 / 事件发生时间能否定位到某几分钟,而不是普通解盘或推运。 +- 普通咨询、推运、合盘、补救问题交给 `jyotish-vedic-astrology`,不要在这里处理。 + +生时校正的方法学、访谈策略、证据边界与候选表达规则只定义在本 Skill 及其 references。system prompt 只保留安全、权限、隐私、工具和运行边界,不得复制、压缩或另写一套校时方法学,也不得用 system prompt 覆盖本版本政策。 + +## 2. 必须先读与服务器权威 + +进入任何一轮实质工作前读取(服务器会随 Dossier 提供投影,缺文件时以服务器 Dossier 为准): + +1. `references/evidence-model.md`:证据种类、日期精度、原文引用、修订链、服务器持有 ID。 +2. `references/conversation-strategy.md`:OpeningPolicy、ConversationFocus、长会话记忆、批量证据与追问策略。 +3. `references/candidate-comparison.md`:candidate / accepted / confirmed 三层语义与表达边界。 +4. `references/technique-routing.md`:技法按主题调用,D9/D10 核心,不一次性调用所有分盘。 +5. `references/truth-consent-boundaries.md`:真实性、同意与选择政策。 + +服务器是下列信息的唯一权威:Skill 绑定版本、Case/Session 身份与状态、`ConversationFocus`、`CaseConversationSummary`、evidence/focus ID、事件状态与修订链、候选范围与评分、采用/确认权限、工具执行、持久化和计费。Agent 只能解释服务器投影并选择自然表达,不得从对话文本、上一条 assistant 消息或 recent turns 重建权威状态。 + +每次 attempt 必须先完成真实 Skill 绑定和 Case 加载,之后才能执行 action。失败或重试 attempt 的部分文本、工具结果与推断不得当作已提交事实;只依据服务器提交成功的 attempt 与 receipt。 + +## 3. Case 状态与只读边界 + +服务器 Dossier 会给出当前 `status`。按表行动: + +| status | 允许动作 | +|---|---| +| `draft` / `collecting_evidence` | 继续收集/修订带日期事件;可读取诊断。`next_user_action.id=adopt_representative` 时本轮结果是采用代表性时间,**不得**同时追问;仍有挡住出牌的 `next_followup` 时继续收集,**不得**提供候选。`selection_allowed` 不够作为出示卡片的理由;提出门看 `propose_allowed` 且访谈已停或用户喊停 | +| `candidate_ready` | 可比较候选、说明当前边界;仍可继续补证据 | +| `candidate_accepted` | 已采用代表性时间。采用后先按该分钟核最多两件前事,对不上可改选其他候选;核对结束再用这个时间看盘。`unique_minute_path=closed_at_representative` 时本会话以此收口,**不得**进入唯一分钟确认 | +| `needs_rebaseline` | 出生资料基线已变化,候选失效;只允许重新收集/修订事件,禁止引用旧候选 | +| `paused` | 可继续访谈;不要声称结束 | +| `confirmed` / `closed` / `abandoned` / `superseded` | terminal Case,只读历史;不得追加/修订/确认证据,不得采用/确认候选,不得关闭第二次 | + +- terminal Case 的只读限制由服务器强制;Agent 不得用换工具、换措辞、重试或旧 focus 绕过。用户要继续校正时,说明需要走显式新建 Case 的入口。 +- 同一用户可以保留多个可恢复 Case;首页显式新建与历史 Session 精确恢复是两条不同入口,不得因存在旧 Case 强制回到旧 Session。 +- 历史 Session 必须恢复对应的精确 Case/Session;不得把另一个 resumable Case 的上下文混入当前会话。 + +## 4. OpeningPolicy + +服务端首次提供 opening brief:Case 状态、当前搜索窗口(`candidate_range`)与来源(intake 声明的不确定档)、做法三句要点、六类领域清单(升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤)。Agent 按下列三句模板自然开场,不得要求先准备一套材料,也不得写具体年份: + +1. 一句当前搜索窗口与核对做法。 +2. 一句「最后给区间和代表分钟,不给精确到秒」。 +3. 一句「想到几件说几件,有大概年月就行」并点出上述六类。 + +开场必须满足: + +- 一条消息可以报多件;想到几件说几件,有大概年月即可。用户每说一批后由服务端问「还有吗」,例子只列还没提过的具体事物、最多 4 个。用户说「没有了 / 就这些 / 记不清」后改为从已说的事做锚定追问。不得用生日推年份写进题干,也不得重复开场邀请。 +- 允许模糊日期:可以先说大概年份、阶段或范围;如确有信息增益,后续再澄清,不诱导猜测月份或日期。 +- 首题保持采集题身份(`collect:other:*`),题干写成「先说你最容易想起的一两件,年月大概就行」。 +- 至多一个主问题且唯一来源:每轮当前问题只能由服务端建立 `ConversationFocus` 并通过界面问题槽呈现。Agent 回复正文只做承接与解释,不得自行提出、复述、改写或预告问题;正文内容不参与问题槽判定。 +- 不机械复述 opening brief,不泄露服务器字段、内部状态对象或出生资料明文。 +- 用户说出出生时间或时段时,不得回答『以你说的为准』或改写搜索窗口;服务端会固定回复范围在开始时已定、过程中不改。 + +## 5. ConversationFocus 与意图承接 + +`ConversationFocus` 是服务器持久化的当前对话目标,至少包含 `id`(即 `focusId`)、`questionId`、`intent`、`targetEvidenceId`、目标领域/类型、预期回答结构、状态与时间。Agent 可做意图分类,但服务器必须验证目标仍为 `active`。 + +- “是的 / 不是 / 大概那年 / 后来改了 / 不记得 / 不想回答 / 换个方向”等承接、拒答、确认和修订,必须依赖服务器给出的 active focus。 +- 拒绝、跳过、解决或修订既有目标时,工具调用必须引用服务器提供的 `focusId`;涉及既有证据时还必须引用对应 `evidenceId`。用户对已有 pending 说“对/是”时,`rectification-confirm-evidence` 可以省略 `focusId`,尤其当 active focus 是无 `target_evidence_id` 的 opening focus 时,不得用它烧掉后续事件确认。 +- 不得从 assistant 上一句倒推拒答目标,不得仅靠 pending revision 或中文正则构造 active focus,也不得把脱离上下文的承接词保存成新事件。 +- 没有 active focus、focus 已 resolved/declined/skipped/superseded、或当前表达可能指向多个目标时,只做一句简短澄清;不得猜测或写 evidence。 +- 当前轮用户主动、明确、无歧义地提出全新事件时,可按新事件处理;若需要后续问题,由服务器建立新的 focus。 +- 已拒绝(没有发生过 / 这类事都没有过)的目标不得换词重问;跳过的按服务器计划最多重问一次。只有用户主动重开该主题或服务器建立新的有效 focus 才可继续。 +- 性格类点选题只在已经给出目前范围之后、用户点了卡下「再答两道参考题微调排序」才出,分值减半、不淘汰。 + +## 6. CaseConversationSummary 与长会话记忆 + +`CaseConversationSummary` 是长会话的权威记忆,至少投影:confirmed evidence summary、pending revisions、active focus、declined/skipped topics、candidate divergence summary、missing evidence categories、`method_followup_plan`、last result policy。 + +- 选择下一动作、识别已确认事实、避免重复追问、理解候选差异与结果政策时,优先依据服务器提供的 `CaseConversationSummary` 与 `method_followup_plan`。 +- 不要按 `missing_evidence_categories` 轮询迁居。财务、健康与其他经历同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。下一问只跟 `method_followup_plan.next_followup`。收集按信息价值排序(邀请「还有吗」→ 用户年份锚定追问 → 无年份通用补问),问到训练门开;训练门开后先问带年月选择题。带年月池空时先按剩余候选刷新一批带年月题;仍无题则按 `guided_collect_windows` 逐条问(YYYY 年 M 到 M 月、哪一类事),再问跳过线一次,再问尚未覆盖的领域。引导题用选项 + 年/月选择器录入,不得邀请自由打字。时间点题答「没发生」只关那个时点,不关领域。所有线(含引导窗口题、跳过线重问、未覆盖领域题)问完后,或用户说「没有了 / 就这些」后,才交付目前范围;`precision_gate_met` 只上报,不改变出卡时机,门槛未达也不加标注。题干写「现在还剩 HH:MM–HH:MM 里 N 个候选」,不得写「能把两端钟点分开」。性格题只作卡下可选入口「再答两道参考题微调排序」,不点不出。训练门关时只写精确缺口、保持开放,不出「做不了」。不得用生日推年份。已有带日期事件且存在 `discriminating_event_probes` 大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层,不要 offer。占问不挡出牌;职业挡出牌。外貌、体质、胎记或疤痕不得追问。收集经历用自然语言问一件带大概年份的事,set-focus 不要写 choice。只有 `next_followup` 带 `choice_frame`(冲突探针、定向补事「有没有」、候选已经分不开或采用后核对前事)时才写 A/B/C/D 点选卡;题干由你写成自然语言,时间范围、领域和语义目标以服务器探针为准,不得发明年份,不得改写时间范围;不要逐字复述服务器的事件家族标签,也不要把标签里的多个例子全堆进一句。结合最近对话只选一个用户最容易回答的口语入口,不要问两套盘哪个更像。正文不要复述选项。「先这样」由服务器补全。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,本轮零追问。`next_user_action.id=verify_adopted_time` 时本轮只核一件前事,不要 offer、不要看盘;A 写入并 compare,C 关闭该问,对不上可改选。`id=start_consultation` 时请用户用当前采用时间看盘。`deferred_followup` 留给用户以后再补,不得当成本轮问题。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问,不得 offer。 +- recent turns 只是有界的原文引用窗口,用于核对当前措辞、quote 和局部承接;不得把 recent turns 当作唯一记忆,也不得用截断历史覆盖 summary。 +- summary 与 recent turns 看似冲突时,不自行裁决或默默改写事实:以服务器状态为准;需要用户确认时围绕 active focus 只澄清一个关键点。 +- 超过长会话窗口后仍不得忘记已确认证据、pending revision、拒答主题或 active focus。 + +## 7. 批量证据与日期真实性 + +一次用户消息可包含多件事件。优先使用服务器提供的批量 proposal/confirmation 服务,并遵守逐项原子语义: + +- 每件事件独立保留用户原话 `quote`、`kind`、`domain` 和真实 `date precision`;不得合并、拆错主体或要求用户逐条重发。 +- 服务器逐项返回 `accepted` / `needs_clarification` / `rejected`;Agent 按每项结果分别处理,不得让一条模糊或拒绝项阻塞同批清晰项。 +- 清晰且 quote grounding 通过的新事件必须走批量服务写入;不要对同一句用户消息里的多件事件逐条 propose+confirm。`rectification-confirm-evidence` 只用于用户对已有 pending 明确说“对/是”。 +- 证据有效写入后,服务器会按当前账本重算候选。不要等用户说“没有更多了”才 compare;同一证据指纹不要再 compare。不要调用新的扫描工具。 +- 证据轮正文只写一句复述,格式「记下了:年 月 事件短语(、…)。」例如「记下了:2016 年 9 月入学、2020 年 6 月毕业。」不得加评价句,不得写「很有帮助 / 很有价值 / 很有分量 / 特别有用」。范围变化由服务器接到正文后面。 +- 批量结果中的 evidence item `accepted` 只是该项被服务接纳处理,不等于候选 `accepted`;清晰项在批量路径上可由服务器直接 `confirmed`。 +- 复述任何事件日期必须使用服务器 `display_date_label`。日级不得说成“年份已确定为 YYYY”。用户确认“是/对”不得改 `date_precision`。 +- `needs_clarification` 不得猜补日期、主体、事件身份、主动/被动、原因或人物关系;`rejected` 不得伪装成已记录。 +- 修订必须生成 superseding revision,引用 active `focusId` 与目标 `evidenceId`,不得覆盖历史;pending revision 不自动确认。 +- 日期精度真实保留:`year` / `month` / `quarter` / `day` / `range` / `unknown` 按用户原话保存,范围不得取中点,只有服务器目标已明确年份时才可把用户补充的月份/季度并入修订。 +- 批量服务与单项工具都必须依赖服务器幂等键;重试不得重复创建或确认 evidence。Agent 不自行生成 evidence/focus ID。 + +## 8. 可调用工具与输入边界 + +只调用服务器提供的 `rectification-*` 工具,包括 read-case、set/resolve-focus、批量 evidence、单项 proposal/confirmation/revision、candidate comparison/offer/accept/confirm 与 close-case。工具 input 只含服务端合同要求的最小引用(如 caseId、focusId、evidenceId、quote、proposedKind),**绝不**传: + +- userId、出生日期/时间/地点/时区、candidate range、完整 events 数组、分数与阈值、confirmationAllowed/selectionAllowed、profile 写入目标。 + +工具结果只读取;事实、ID、评分、范围、状态、持久化、幂等与权限一律以服务器为准。工具执行对用户保持静默:不得叙述读取 Skill、Case 已加载、调用工具、建立草稿、读取诊断或呈现快照,也不得自行生成“本轮做了什么”“执行步骤”“使用技法”或 Activity 状态文案;运行状态和实际方法 receipt 只由服务器公开凭证展示。 + +## 9. candidate / accepted / confirmed 语言边界 + +- `candidate`:引擎对当前证据的归一化比较结果,称“当前候选 / 相对支持度”,**不得**称概率、置信度或确定性。 +- `accepted`:用户明确选择的当前排盘时间,称“校正采用时间”,**不得**称“已确认唯一出生时间”。 +- `confirmed`:通过服务器确认门且用户明确同意,称“已确认校正时间”。 +- `session_outcome=adopt_representative` / `next_user_action.id=adopt_representative`:本轮**有结果**,结果是采用代表性时间作当前排盘。正文应自然说明代表性候选可用于当前排盘,但它不是已确认的唯一出生分钟;不要使用固定收口句式。不要调用 confirm。只有这时才调用 `rectification-offer-candidates`。服务器会拒绝访谈未停且用户未喊停的 offer。`collecting_evidence` 且仍有挡住出牌的 `next_followup` 时不得 offer/accept。`propose_allowed` 需要可评分事件≥4、领域≥3、诊断稳定,或事件吻合率≥80%;唯一领先和宽度≤5只挡确认门,不挡出示代表性时间卡。精度阶段追问在收集达到训练门、选择题问完后才问,且不挡出牌。KP 观察不计分、不挡提出门。 +- 确认门以 `latest_result.confirmation_gate` 为准。`unique_minute_path=closed_at_representative` 或任一 blocker 未通过时,不得把唯一分钟确认当下一步;用户仍可 accepted 代表性候选。 +- `vedastro_minute_sensitive` 为 `not_evaluated` 表示尚未跑通,不等于 fail,但缺它不能写 confirmed。 +- 若 `vedastro_minute_sensitive` 为 `passed` 但 `public_aa_holdout` 为 `not_ready`,可以说官方分钟层已区分相邻分钟,仍必须说公开密封集尚未达标,不能确认唯一分钟。 +- `public_aa_holdout` 为 `not_ready` 时 `unique_minute_path` 必须是 `closed_at_representative`:不得声称已校准到精确分钟,也不得把确认门放到更细宽度或发布准确率。 +- 未达到唯一分钟确认门时,任何“就用 HH:MM”都只能进入 accepted;只有 `confirmation_allowed=true` 且用户同意才可写 confirmed。 +- 若不可分 blocker 为 `blocked`、宽度大于 5、top `tied_minute_count` > 1,或 `confirmation_allowed=false`,正文必须说这是一段不可分区间,把代表分钟称为代表性候选,不得说已定位到唯一分钟。 +- 分钟窗口扫描只在服务端。即使高吻合、宽度 ≤5、`can_apply`/`propose_allowed`,仍写 `candidate_range_not_birth_time_truth`。 +- 出牌/采用轮正文只写三句:目前范围与代表分钟、对照了几件经历与事件吻合率、边界句「这只是代表性候选,不是已确认的唯一出生分钟」。卡片标题用「目前范围」。门槛未达时卡下写「再对照几件经历会更准」,不得邀请自由打字。禁用「这次给出」「结束」「最终」。八法验证报告(筛选窗、方法1–8、Technique Audit Table)由服务端 `skill_verification_report.markdown` 渲染在卡片下方折叠块「查看验证报告」,**不得**写入助手气泡。宽度、双轨只抄 `skill_verification_report` 的 `width_minutes` / `dasha_agreement`。分盘上升只抄 `skill_verification_report.sign_by_candidate`,不得自行按换升时刻推算。 +- 80%/60% 只描述**事件吻合率**(高度/中度/低度拟合),**不得**写成“已确认唯一出生分钟”。 +- 不得在同一回复中一边要求继续补证据、一边提供采用候选。 +- 不得伪造出生分钟、分数、权重、事件 ID、分盘事实或确认门结果。 + +## 10. 输出与停止条件 + +- 简体中文。访谈按 skill 路径 C:先用自然语言收集带大概年份的经历;只有候选已经分不开时才生成可点选的 A/B/C/D 主题问卷。允许模糊日期、允许分多轮。**不得**一进场就出点选卡,也不得先逼 10–15 条事件长表。 +- 每轮最多一个主要问题;完整回复可以零问题,不为了延续对话强行追问,不生成三条推荐问题。 +- 用户询问“为什么问这个 / 现在到哪一步 / 还需要多少信息”时,基于服务器状态直接回答,不把问题当作事件。 +- 用户说“不知道 / 记不清 / 不想回答 / 换个方向”时,按 active focus 关闭或跳过该目标;用户说“目前没有 / 没有更多事件”时,不再轮换证据领域,也不要求结束、暂停或保存进度。 +- 不得询问外貌、体质、胎记或疤痕。D9/D10 类型表是校时方法,写「该分钟下 D9/D10 升 X,与用户所述特质的对应/冲突」,不是咨询命运承诺。职业对照本命第 10 宫和 D10,允许类型表。占问只问一次;有问起时间则观察,没有也不挡出牌。`internal_observations` 可用于选题,类型对照写入验证报告。若用户消息以「盘外核对(不计分)」开头,不得写入可评分证据。 +- 精度阶段按本命上升 → D9 → D10 → D4 居所 → D5/D24 成就收窄;家人走 D12/D7/D3 方法覆盖。财务走 D2/D11、健康走 D30,与其他领域同权计分,均不得混进 D4。Pada / Hora / Ghati / Bhava / Pranapada / KP 子主只展示换升,不确认唯一分钟。 +- 采用后按采用分钟核最多两件服务器探针前事;对得上写入并重算,对不上可改选其他候选。不得声称唯一分钟,也不自动进入咨询 Agent。 +- 采用候选后自然说明 accepted 与 confirmed 边界;`verify_adopted_time` 时必须核一件前事,核对结束或用户先这样才请看盘。不主动关闭 Case,Session 会保留并可日后继续。 +- 不再有固定 10–15 个事件长表、外貌/体型/疤痕主评分、或“稳定确定到精确分钟”的承诺。A/B/C/D 主题问卷只在候选已经分不开或采用后核对前事时使用。80%/60% 只描述事件吻合率。 +- 无法验证时如实降级并说明受限,不得把内部一致性伪装成全球顶级精度。 + +## 11. 上游同步边界 + +方法源只在本 Skill 与 references。不得把本 Skill 内容反向写回 `yinduzhanxing` 上游快照,也不得在同步时自动覆盖商业 Skill。 diff --git a/skills/jyotish-birth-time-rectification/versions/10.0.28/references/candidate-comparison.md b/skills/jyotish-birth-time-rectification/versions/10.0.28/references/candidate-comparison.md new file mode 100644 index 00000000..ec2ac42e --- /dev/null +++ b/skills/jyotish-birth-time-rectification/versions/10.0.28/references/candidate-comparison.md @@ -0,0 +1,84 @@ +# Candidate Comparison(V9) + +候选比较是服务器计算产物,Agent 只负责解释与引导,不负责产生候选、分数或范围。 + +## 1. 三层语义 + +| 层 | 含义 | 表达 | +|---|---|---| +| `candidate` | 引擎对当前证据的归一化比较结果 | “当前候选”“相对支持度” | +| `accepted` | 用户明确选择的当前排盘时间 | “校正采用时间” | +| `confirmed` | 通过服务器确认门且用户明确同意 | “已确认校正时间” | + +- `candidate_accepted` 不是“唯一出生分钟已确认”,默认仍可继续补充证据。 +- accepted 后用户仍可在同一批有效候选中改选(幂等 RPC 支持)。 +- confirmed 只能由服务器确认门 + 用户明确同意触发,同时写 `completed_at`。 + +## 2. 何时提供候选 + +- 只有本轮完成 `rectification-offer-candidates` 且返回 `selection_allowed=true` 时,界面才展示候选卡。 +- `selection_allowed` 只表示可以采用代表性时间,**不是**本轮必须出示卡片。提出门看 `latest_result.propose_allowed`,并且没有挡住出牌的 `method_followup_plan.next_followup`(占问和精度阶段追问不挡;职业挡出牌)。唯一领先和宽度≤5只挡确认门。 +- `next_user_action.id=adopt_representative`,或用户停止且 `on_user_stop` 为 adopt 时,本轮才 offer/accept。服务器会拒绝访谈未停的 offer。这是采用代表性时间,不是 confirmed。 +- 继续收集证据时不得边追问边提供采用。 +- 候选卡内容来自持久化 Candidate Snapshot(`agentic_rectification_results`),不是 Agent 文本解析。 +- 候选卡按一行至多三列并排:每列一个候选分钟,写相对可能性、性格处事、经历对照、往后 12 个月事件窗;「更像这个」即采用。不预标「排盘用」。Agent 正文在出牌轮**不得**复述八法表格或 Technique Audit。 + +## 3. 表达边界 + +- 相对支持度是候选间归一化比较,**不是**概率、统计置信度或确定性。卡片上的「相对可能性」是答题后的后验百分比,同样不是引擎置信度。80%/60% 只描述事件吻合率。 +- 出牌轮正文不写事件–Dasha–Gochara 表、D9/D10 类型对照和技法审计;那些只出现在折叠的验证报告里。不暴露隐藏分钟证据或把分数说成唯一分钟概率。分盘上升只抄 `skill_verification_report.sign_by_candidate`,不得自行按换升时刻推算。 +- 候选范围必须说明“待核对边界”,不得表述为已确认出生分钟。 +- 外部验证状态按服务器字面读取:`not_evaluated` 表示未调用(入口门未就绪),不是“调用了但失败”。 + +## 4. 证据变化与重算 + +- 证据有效变化时由服务器重算候选;Agent 不必等用户说“没有更多了”才 compare。 +- 相同 evidence 指纹 + 引擎版本复用缓存;不要对同一指纹再 compare。 +- 分钟窗口扫描只在服务端,结果进入候选卡 / 不可分平台语言。不得把若干事件说成已确定到 ±5 分钟。 +- 普通澄清轮若不改变账本指纹,不重复播报。 +- 出生资料基线变化 → `needs_rebaseline`,旧候选失效;不得静默继续用旧结果。 +- `needs_rebaseline` 下不引用旧候选、不提供采用。 + +## 5. 不可分平台与确认门(必须说出来) + +服务器 `latest_result` 含 `confirmation_gate`、`engine_indistinguishable_width_minutes`、`confirmation_allowed`、`selection_allowed` 与 `margin_percent`(若有)。`confirmation_gate` 是确认门权威,不是让 Agent 另算一分钟。折叠验证报告的宽度、双轨、分盘星座只抄 `skill_verification_report`(`width_minutes` / `dasha_agreement` / `sign_by_candidate`),不得用引擎原跨度或已淘汰分钟。Agent 正文不得再写这些表。 + +- 宽度大于 `maxConfirmationWidthMinutes`(5),或 top 候选 `tied_minute_count` > 1,或 `confirmation_allowed=false` 时:正文必须说这是**一段不可分区间**,必须把代表分钟说成**代表性候选**,不得说已定位到唯一分钟,也不得学本地扫分钟后的 1 分钟尖峰。 +- `vedastro_minute_sensitive` 为 `not_evaluated` 表示官方分钟敏感校验尚未跑通,不是 fail;缺它不能写 confirmed。 +- 若官方分钟层已 `passed` 但 `public_aa_holdout` 为 `not_ready`:可以说已区分相邻分钟,仍不得确认唯一分钟或发布准确率。 +- `public_aa_holdout` 为 `not_ready` 时不得声称已校准到精确分钟,也不得把确认门放到更细宽度或发布准确率。 +- 用户仍可 accepted 代表性候选;accepted ≠ confirmed。`session_outcome=adopt_representative` 时自然说明代表性候选可用于当前排盘、但不是已确认的唯一出生分钟,不要使用固定收口句式。`unique_minute_path=closed_at_representative` 时不得把确认当下一步。 +- `confirmation_allowed=true` 才允许进入唯一分钟确认门;平台结果禁止把 `confirmation_allowed` 说成已确认。 +- 候选卡仍可展示代表性时间;Agent 不得把该时间写成“已校正到 HH:MM”。 + +## 6. 出生时间来源标签 + +服务器 Dossier / GET 快照的 `birth_time_source`(缺省按 `approximate`)决定任何指代「用户报上来的那个时间」的措辞。打分与搜索窗中心仍用 `reported_birth_time`,本规则只约束表达。 + +| 来源 | 可称 | 不得称 | +|---|---|---| +| `hospital_record` | 「你的出生记录时间」 | 「已确认的出生分钟」 | +| `approximate`(含存量 `family_exact`) | 「你填的大概时间」「家人记得的时间」 | 「你的出生时间」 | +| `period_only` | 「你给的时间段」 | 「你的出生时间」;不得逼用户补一个钟点 | + +校正产物自己的标签不变:交付区间是目前范围(`rectified_window`),代表分钟是代表性候选(`representative_time`),采用之后是校正采用时间(`accepted`)。不得把代表分钟说成已确认的出生分钟。 + +与填报时间比较时:`hospital_record` 可写「出生记录时间 HH:MM」并如实给出与目前范围的差值,不给「以记录为准 / 以证据为准」的倾向;其余来源只写「与你填的大概时间相差 N 分钟」。 + +### 6.1 记录与目前范围冲突(`hospital_record` 落在范围外) + +产品负责人 2026-09-14 拍板:**记录优先,分歧如实呈现。** 依据两条:封存 20 例上六题后头名簇命中率是 0.80 / 0.55 / 0.35(±10 / ±30 / ±60 分钟窗,见 `docs/research/cluster_width_2026_09_14.md`),宽窗里有一半以上概率排错头名,证据强度撑不起推翻书面记录;但医院记录确实会错(事后补记、四舍五入到 5 分钟整、家属转述),所以也不能反过来宣布校正结果无效。 + +- **D1 默认仍按出生记录时间排盘。** 这是既有行为——采用是用户主动动作,不采用就继续用填报时间。本节只要求把它说出来,不改行为。 +- **D2 冲突时校正区间是「证据倾向」,措辞写满三层:** ①默认还是按你的出生记录时间排盘;②这些经历指向的是另一段时间,相差 N 分钟;③你可以改用校正结果,也可以继续用记录。 +- **D3 采用入口改措辞:** 不写「采用」,写「改用校正结果」,并在动手的地方再说一次「之后的排盘会从出生记录时间 HH:MM 换成 HH:MM」。仍是同一个采用按钮,不新增入口、不加确认弹窗。 +- **D4 不得宣布任何一方无效。** 禁止「你的出生记录错了 / 记录不准 / 以证据为准」,也禁止「校正结果无效 / 不作数」。只陈述差值与各自依据。 + +记录落在目前范围内时不适用本节:仍写「出生记录时间 HH:MM,落在目前范围内」,采用入口措辞不变。采用在冲突态下仍然只是校正采用时间,不是已确认的唯一出生分钟。 + +## 7. 保存边界 + +- accepted 写入 `active_birth_time`,保留 `reported_birth_time` 原填报,不写兼容 `birth_time`。 +- 采用后界面按采用分钟重算本命宫位表,并折叠展示本轮技法审计。这不是唯一分钟确认,也不自动进入咨询 Agent。 +- confirmed 同样保留原填报;不自动写入,需要用户明确同意。 +- 失败、空流、Skill 未加载或未完成必要工具链时不保存、不扣费。 diff --git a/skills/jyotish-birth-time-rectification/versions/10.0.28/references/conversation-strategy.md b/skills/jyotish-birth-time-rectification/versions/10.0.28/references/conversation-strategy.md new file mode 100644 index 00000000..d00de1bc --- /dev/null +++ b/skills/jyotish-birth-time-rectification/versions/10.0.28/references/conversation-strategy.md @@ -0,0 +1,107 @@ +# Conversation Strategy(V10) + +生时校正访谈按 skill 路径 C:先用自然语言收集带大概年份的经历,再在候选已经分不开时由服务器锁定时间范围和事件家族,由你写成一句具体生平题干(某年或某月是否搬过家、高考是否发挥失常),用 A/B/C/D 点选卡回答同一件事的吻合程度;不是 10–15 条事件长表,也不是无结构闲聊,更不是让用户给两套盘排序。服务器持有事实、状态、权限、焦点与长会话记忆;Agent 负责意图理解、把问卷说清楚、并选择一个有信息增益的下一步。 + +## 1. 每轮上下文优先级 + +每轮先按以下优先级理解会话: + +1. 当前 Case 的服务器状态与读写权限。 +2. `CaseConversationSummary`:confirmed evidence、pending revisions、active focus、declined/skipped topics、candidate divergence、`method_followup_plan`、last result policy。不要把 `missing_evidence_categories` 当下一问。 +3. 当前用户消息。 +4. recent turns:只作为有界原文引用窗口,辅助 quote grounding 和局部措辞理解。 + +recent turns 不是权威记忆,不得依赖“上一条 assistant 问了什么”的倒推、正则匹配或被截断的聊天记录重建 Case 状态。summary 与局部文本不一致时,以服务器状态为准;若用户意图仍不唯一,只澄清一个关键点。 + +## 2. OpeningPolicy + +首次开场只使用服务器 opening brief 中的 Case 状态、当前搜索窗口(intake 不确定档)、做法三句要点与六类领域清单,并自然满足: + +- 三句模板:当前窗口与核对做法;「最后给区间和代表分钟,不给精确到秒」;「想到几件说几件,有大概年月就行」并点出升学、第一份工作、搬家、恋爱结婚、家里的大事、生病受伤。 +- 一条消息可以报多件。不索要 10–15 条事件长表,不要一进场就出 A/B/C/D。用户每说一批后由服务端问「还有吗」,例子只列还没提过的具体事物。用户说「没有了 / 就这些 / 记不清」后改为从已说的事做锚定追问。不得用生日推年份,也不得重复开场邀请。 +- 接受“大概某年 / 那几年 / 某个阶段”等模糊日期,不诱导猜月份、日期或精确时点。 +- 不得写具体年份,不得要求先准备材料。 +- 首题 `collect:other:*` 题干写成「先说你最容易想起的一两件,年月大概就行」。 +- 至多一个主问题;开场可以零问题。 +- 不固定复述身份、opening brief 原文或服务器字段。 + +区分阶段的题干由你写成自然语言;时间范围和事件家族以服务器探针为准,不得发明年份,不得改写时间范围。例如把锁定的 2015 年和搬家写成“2015 年前后你是否搬过家?”,把锁定的 2018 年 3 月写成“2018 年 3 月前后你是否入职或职责加重?”,把已有高考经历写成“高考的时候是否发挥失常?” + +## 3. 一轮的基本形态 + +1. 先判断用户意图:新事件、批量事件、补日期、修正旧事实、回答上一问、确认/否认、询问进度或原因、拒答/换方向、查看或采用候选。 +2. 先读取服务器 Case、summary 与 active focus;静默完成必要的工具调用后再输出答案。正文不叙述内部执行步骤,也不生成 Activity/技法凭证文案。 +3. 自然回应本轮内容。证据轮正文只写一句复述:「记下了:年 月 事件短语(、…)。」不评价价值,不写「很有帮助 / 很有价值 / 很有分量 / 特别有用」。范围变化由服务器接在后面。 +4. 清晰项先处理;若仍需追问,只保留一个最有信息增益的主问题。完整回复可以没有问题。 +5. 不允许在同一回复中既要求补证据、又提供采用候选;不生成三条推荐问题。 +6. `next_user_action.id=adopt_representative` 时本轮只解释结果并邀请采用,零追问(除非有 active focus)。`id=verify_adopted_time` 时本轮只核一件前事,不要 offer,不要看盘。仍有挡住出牌的 `next_followup` 时不得出示采用卡。提出门看 `propose_allowed`。精度阶段追问和占问不挡出牌;职业仍挡。不得询问外貌、体质、胎记或疤痕。宽度大于 5 仍可出示代表性时间卡,不得为把不可分区间问到 5 分钟以内而继续 A/B/C/D。`unique_minute_path=closed_at_representative` 时不得把唯一分钟确认当下一步。 + +## 4. ConversationFocus + +active `ConversationFocus` 是承接型意图的唯一目标来源。它由服务器持久化并提供 `focusId`、目标 `evidenceId`(如有)、intent、预期回答结构和状态。 + +- “是的 / 不是 / 对 / 不对 / 大概那年 / 后来改了 / 不记得 / 不想回答 / 换个方向”只有在存在唯一 active focus 时才能解释为回答、拒答、确认或修订。 +- 拒绝、跳过、解决 focus 时,工具调用必须引用 active `focusId`;修订既有 evidence 时同时引用目标 `evidenceId`。用户对已有 pending 说“对/是”时,确认工具可以省略 `focusId`;opening focus(无 `target_evidence_id`)不得因第一条确认被 resolve。 +- 无 active focus、focus 已非 active、目标已被 supersede、或一句话可能指向多个问题时,简短问清“你指的是哪一件/哪一个时间点”;不得猜测,不调用 evidence 写工具。 +- 脱离 active focus 的“是的 / 不是”不是新事件。不得从 assistant 上一句倒推目标,不得只用 pending revision 构造 `active_followup`。 +- 当前消息若主动、明确陈述全新事件,可独立进入 evidence 流程;需要追问时由服务器建立新 focus。 +- 服务器验证 focus 已失效时,停止该动作并基于最新 summary 重新回应,不沿用旧目标。 + +## 5. 自然叙述与批量 evidence + +用户一段话中可以包含多件事件。应优先走服务器批量服务: + +- 每件事件分别保留原话 `quote`、`kind`、`domain`、主体和日期精度,不合并,不要求逐条重发。 +- 服务器对每项独立返回 `accepted`、`needs_clarification` 或 `rejected`。一项失败不改变其他项结果。 +- 新事件优先走批量服务;一句里两件及以上事件时只允许批量。清晰项在批量路径上可由服务器直接 `confirmed`,不要再逐条 propose+confirm。不要让模糊项阻塞清晰项。 +- 多个模糊项同时存在时,只选择信息增益最高的一项追问一个关键点,其余维持待澄清,不连续抛出问题清单。 +- `needs_clarification` 只问缺失的关键事实;不猜日期、主体、事件身份、动机、因果、主动/被动或人物关系。 +- `rejected` 如需解释,只说明用户可理解的边界,不伪装成已记录。 +- 批量 evidence item 的 `accepted` 是服务处理结果,不是候选采用状态;清晰项的最终 `status` 以服务器返回为准,批量路径上可以为 `confirmed`。 +- 询问进度/原因、拒答、查看结果、采用候选,以及无唯一 active focus 的承接词,都不是新事件。 + +## 6. 确认、修订、拒答与换方向 + +- 确认既有事实:必须有对应 `evidenceId`;确认词本身不创建新 evidence。无匹配 pending-target 的 focus 时可省略 `focusId`。 +- 修订既有事实:必须有 active `focusId` 和目标 `evidenceId`,生成 superseding revision,不覆盖历史;pending revision 不自动确认。 +- 用户明确“不知道 / 记不清”:将 active focus 解决为 skipped;跳过的线按服务器计划最多换一种问法再问一次,再次跳过才永久关闭。回执「记下了,这题先放着,后面换个问法再问一次。」 +- 用户明确“没有 / 不想回答 / 换个方向”:decline active focus;已拒绝(没有发生过)的不得换词重问。采集题「这类事都没有过」走 declined,回执「记下了,这条按没有发生过记。」时间点题答没发生不关领域。 +- 用户主动重新打开曾拒绝主题时,可让服务器建立新 focus;否则 declined/skipped topics 以 `CaseConversationSummary` 为准。 +- 用户说“目前没有 / 没有更多事件”时,停止轮换证据领域;不要求结束、暂停或保存进度。 +- 若没有其他具备信息增益的问题,可以直接说明当前边界或自然结束本轮。 + +## 7. 追问策略 + +追问必须能澄清事实、提高真实日期精度、补足必要方法层或区分候选;否则不提。优先级: + +1. 服务器 `CaseConversationSummary.active focus` 指定的唯一目标。 +2. `method_followup_plan.next_followup` 指定的下一方法层。收集按信息价值排序(邀请「还有吗」→ 用户年份锚定追问 → 无年份通用补问),问到训练门开;训练门开后先问带年月选择题。带年月池空时先按剩余候选刷新一批带年月题;仍无题则按 `guided_collect_windows` 逐条问,再问跳过线一次,再问尚未覆盖的领域。引导题用选项 + 年/月选择器录入,不得邀请自由打字。出卡须 `precision_gate_met` 或用户说「没有了 / 就这些」。题干写「现在还剩 HH:MM–HH:MM 里 N 个候选」,不得写「能把两端钟点分开」。性格题只作卡下可选入口「再答两道参考题微调排序」,不点不出。已有带日期事件且服务器给出大运冲突探针时,先问该前事筛窗,`source=event_probe` 挡住出牌,不要继续轮询方法层。迁居不进领域轮询,只在 `d4_refine` 精度阶段问搬家/住处。财务、健康与其他经历同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。不得询问外貌、体质、胎记或疤痕。收集经历用自然语言。只有候选已经分不开、冲突探针、定向补事「有没有」或采用后核对前事时,`choice_frame` 才提供点选卡;时间范围和事件家族由服务器 `discriminating_event_probes` 锁定(Vimshottari+Narayana 大运/副运起点的年或月差,没有可问边界时才用出生年+年龄带)。题干和 A/B/C/D 由你写成自然语言,A/B 是同一件事的吻合程度,不要照抄 hint,不要问两套盘哪个更像或可能性高低,不得发明年份,不得改写时间范围。Nakshatra pada / Hora / Ghati / Bhava / Pranapada / KP 子主换升只展示,不阻断采用。`next_user_action.id=adopt_representative` 时 `next_followup` 为空,不得把 `deferred_followup` 当成本轮问题。`id=verify_adopted_time` 时本轮只核一件前事。仍有挡住出牌的 `next_followup` 时即使 `selection_allowed` 也继续问。 +3. candidate divergence / `internal_observations` 显示真正能区分候选的主题。D9/D10 观察用于选题,并在出牌轮写入类型对照(校时方法,不是命运承诺)。 +4. pending revision 的一个关键歧义。 +5. 已有证据的必要稳定性补强。 + +不要按 `missing_evidence_categories` 轮询迁居。财务、健康与其他领域同权:服务器按 `method_followup_plan.next_followup` 主动问,用户说了就记、就计分。不是 SQL 类别轮询。`stop_domain_rotation=true` 时停止领域清单。一轮最多一个主要问题。用户询问“为什么问这个 / 现在到哪一步 / 还需要多少信息”时,直接说明目的、当前状态和边界,不绕开问题继续索取证据。 + +## 8. 日期精度 + +- `year`:只说年份;复述用 `display_date_label`(如 `2024年`)。 +- `month`:明确到月份;复述如 `2024-05`。 +- `quarter`:明确到季度。 +- `day`:明确到日期;复述必须是 `YYYY-MM-DD`,禁止说成“年份已确定为 YYYY”。 +- `range`:只有范围,不得擅自取中点当事实;复述用 `from–to`。 +- `unknown`:日期不明;可保留背景,但不得当作高权重校正证据。 +- 用户确认“是 / 对”不得改 `date_precision`。 +- 用户只补月份/季度时,只有 active focus 与目标 evidence 已由服务器明确年份,才可合并为 revision;不得猜年份。 +- “大概 3 月”仍按用户真实表达保存,不升级成某一天。 + +## 9. 候选输出与终态 + +- 候选卡负责呈现时间、排名、相对支持度、采用动作与选中状态。 +- 出牌/采用轮正文写入 skill 八法验证报告:候选窗、代表分钟、相对支持、事件–Dasha–Gochara 表、D9/D10 类型对照、技法审计表。卡片仍作 adopt 控件。 +- `relative_support` 不是概率,不能写“准确率 70%”。80%/60% 只描述事件吻合率。 +- candidate、accepted、confirmed 严格分离;accepted 不是 confirmed。 +- `next_user_action.id=adopt_representative` 时本轮结果是采用代表性时间;正文自然说明代表性候选可用于当前排盘、但不是已确认的唯一出生分钟,不要使用固定收口句式。仍有 `next_followup` 时不得出示采用卡。 +- 确认门以 `confirmation_gate` 为准。`not_evaluated` 不是 fail;holdout `not_ready` 时 `unique_minute_path=closed_at_representative`,不得声称精确分钟或发布准确率,也不得把唯一分钟确认当下一步。官方分钟层 `passed` 仍不能单独打开确认门。 +- 若确认门 `confirmation_allowed=false`,或 `confirmation_gate` 的不可分 blocker 为 blocked,必须说不可分区间 / 代表性候选,不得说已定位到唯一分钟。交付轮宽度只抄 `skill_verification_report.width_minutes`。accepted ≠ confirmed。 +- accepted 后按采用分钟核最多两件前事;对得上写入并重算,对不上可改选。不强制看盘,不要求用户结束、暂停或保存进度。核对结束或用户先这样才 `start_consultation`。 +- terminal Case(confirmed / closed / abandoned / superseded)只读:不得新增/修订/确认 evidence,不得采用/确认候选;若用户要继续,指向显式新建 Case。 diff --git a/skills/jyotish-birth-time-rectification/versions/10.0.28/references/evidence-model.md b/skills/jyotish-birth-time-rectification/versions/10.0.28/references/evidence-model.md new file mode 100644 index 00000000..4bc10878 --- /dev/null +++ b/skills/jyotish-birth-time-rectification/versions/10.0.28/references/evidence-model.md @@ -0,0 +1,122 @@ +# Evidence Model(V9) + +证据是生时校正的唯一事实账本。本文件定义证据如何进入、校验、修订与关闭。服务器是证据账本的唯一写入者;Agent 只能提出 proposal。 + +## 1. 证据最小单元 + +一条证据(`agentic_rectification_evidence` 一行)至少包含: + +- `case_id`:所属 Case,由服务器生成。 +- `source_turn_id`:用户消息所在轮次;`source_message_id` 可选。 +- `user_quote`:用户原话的规范化子串。 +- `subject`:主体(`self` 或亲属关系;家庭事件必须显式 `related_person`)。 +- `event_kind`:语义种类(见 §2),不再只保留粗领域。 +- `domain`:评分/路由领域。 +- `occurred_from` / `occurred_to`:真实日期边界,可空。 +- `date_precision`:`year | month | quarter | day | range | unknown`。 +- `summary`:服务器从已验证引用中生成的安全摘要。 +- `status`:`draft | pending_confirmation | confirmed | superseded | rejected`。 +- `supersedes_evidence_id`:修订链指针。 + +## 2. 事件种类(event_kind) + +```text +education_start +education_completion +education_interruption +education_change +education_milestone +career_entry +career_change +promotion +career_pressure +career_exit +business_start +relationship_start +relationship_commitment +relationship_separation +relationship_end +relationship_change +relocation +foreign_move +return +home_change +finance_gain +finance_loss +income_change +asset_change +finance_change +self_health_event +pressure_period +family_event +appearance_note +birthmark_or_scar +occupation_note +horary_query +other +``` + +语义不折叠:`career_entry / career_pressure / career_exit` 不同;`relationship_start / relationship_commitment / relationship_separation` 不同;不得把“开始关系”与“关系变化”混成同一事件。`education_milestone`、`relationship_end`、`return`、`home_change`、`health_pressure` 等与 TypeScript `EVIDENCE_KINDS` / `EVIDENCE_DOMAINS` 对齐,不得再因枚举缺口导致写入失败。 + +领域(`domain`): + +```text +education +career +relationship +relocation +finance +health +health_pressure +family +appearance +marks +occupation +horary +other +``` + +## 3. 日期精度 + +- 用户只给年份 → `date_precision = 'year'`,`occurred_from = YYYY-01-01`(边界),不得诱导编造月份。 +- 用户给年月 → `month`;给季度 → `quarter`;给年月日 → `day`;给区间 → `range`。 +- 相对表达(“刚毕业那年”)必须由服务器结合权威当前时间解析,Agent 不得自行假设年份。 +- 跨午夜、未知时间不伪造具体分钟;`unknown` 精度允许保留。 +- 服务器投影只读字段 `display_date_label`:日级用 `YYYY-MM-DD`,月级用 `YYYY-MM`,年级用 `YYYY年`,range 用 `from–to`。复述必须用该标签;禁止把日级格式化成“年份已确定为 YYYY”。用户确认“是/对”不得改 `date_precision`。更粗的修订若 quote 并没有更粗的日期表达,服务器拒绝 `precision_downgrade`。 + +## 4. 原文引用(quote grounding) + +- `user_quote` 必须能在对应 `source_turn.user_message` 中找到规范化匹配(去空白、去标点后子串命中)。 +- 服务器确认路径必须校验:引用来自本轮用户消息、kind 属于枚举、日期与原文一致。 +- 模型不得凭空补充月份、日期、原因、主动/被动、人物关系。 + +## 5. 修订链(append-only) + +- 事实变化 = 新增 superseding row,旧行标记 `superseded`,永不覆盖/删除。 +- 合法修订:日期更正、日期补全(如“2016 年 + 9 月”合并为 `2016-09`)、事件重分类(同身份)。 +- 非法修订:跨事件覆盖既有 ID(如把“大学入学”改成“搬家”);服务器拒绝并降级为新的 pending proposal。 +- 证据 ID 只能由服务器生成;模型不得提供或覆盖。 + +## 6. 状态迁移 + +```text +draft -> confirmed (当前轮明确事件:proposal 通过原文绑定后,同轮走服务器确认路径) +draft -> pending_confirmation (事实模糊、冲突或需要用户补充) +pending_confirmation -> confirmed (用户明确确认 + 服务器确认路径) +pending_confirmation -> superseded(用户更正,产生修订) +confirmed -> superseded (后续修订使旧事实失效) +draft / pending_confirmation -> rejected (用户否认,保留只读历史) +``` + +- Agent 只能先产生 `draft`;`confirmed` 只能由服务器确认路径产生。服务器确认路径不等于必须额外等待一轮用户回复。 +- 终态 Case(confirmed/closed/abandoned/superseded)禁止新增或修订证据。 +- 同一请求重放不得重复写证据(幂等键 = case + source_turn + quote + kind + summary)。 + +## 7. 评分输入边界 + +- 只有 `confirmed` 证据进入评分账本;`draft` 与 `pending_confirmation` 都不参与评分。 +- `family_event` 进入评分(D12 + D7 + D3 + 六亲宫位)。`other` 只作背景,不推进评分覆盖计数。 +- `appearance_note` / `birthmark_or_scar`:无日期只覆盖访谈;有日期才进上升/一宫辅助评分,不得当主公式。 +- `occupation_note`:与带日期事业事件独立。无日期只覆盖访谈;有日期按 D10 + 本命 10 宫辅助评分,允许事业类型表作校时方法。 +- `horary_query` 只作背景观察,不推进评分覆盖计数,也不计入 4 事件 / 3 领域。 +- 证据变化才触发重算;相同证据指纹复用缓存,不重复评分。 diff --git a/skills/jyotish-birth-time-rectification/versions/10.0.28/references/technique-routing.md b/skills/jyotish-birth-time-rectification/versions/10.0.28/references/technique-routing.md new file mode 100644 index 00000000..50a45ebe --- /dev/null +++ b/skills/jyotish-birth-time-rectification/versions/10.0.28/references/technique-routing.md @@ -0,0 +1,50 @@ +# Technique Routing(V9) + +生时校正是“有日期事件 + Dasha 为主要证据”的校准任务,分盘按主题调用,不一次性调用所有分盘。所有计算只能通过服务端工具;本文件只决定读哪些技法证据,不复制任何引擎实现。 + +## 1. 主证据 + +- 有明确日期(年月级或更精确)的人生事件 + 对应 Dasha 边界是主要证据。 +- 事件原文是用户原话;日期精度按用户真实提供保留。 +- 不把“支持某技法”误当作已完成独立验证;内部一致性不得伪装成全球顶级精度。 + +## 2. 分盘调用层级 + +| 层级 | 分盘 | 用途 | +|---|---|---| +| 核心 | D1(本命) | 全局框架 | +| 核心辅助 | D9、D10 | 关系与事业的主要主题 | +| 主题 | D2/D11(财富)、D3(兄弟姐妹)、D7(子女/伴侣细节)、D12(父母)、D24(教育)、D4(居所/不动产)、D5(成就)、D30(健康压力) | 按主题补充 | +| 仅参考 | D60 | 只作参考,不驱动结论 | + +- 同一轮最多调用 2–3 个相关分盘;D9/D10 之外的分盘必须由当前主题驱动。 +- 未执行、不可用或仅供参考的技法不得显示为已执行。 + +## 3. 按问题域强制调取 + +- 事业:同一件带日期的事业事件必须同时计算 `D10` **和** D1 第 10 宫 / 10 宫主(A10 为事业 Arudha,服务器可用时)。职业说明与带日期事业事件独立,同样对照 D10 与本命 10 宫,**允许**事业类型表作校时方法;无日期只覆盖访谈。 +- 财富:用户主动提供带日期的收入、资产或财务变化时计分 `D2 / D11`。不要主动追问。窗口扫描记录 D2/D11 换升,但不新增精度阶段。 +- 婚恋:`D9 + UL`(UL 为 Upapada Lagna,服务器可用时)。 +- 六亲/家人:`D12` 加 `D7`(子女/伴侣细节)加 `D3`(兄弟姐妹)加 D1 三/四/五/九宫。家人事件进入评分,不只作背景。D3 不另开精度阶段。 +- 外貌/体质/胎记疤痕:本轮访谈不追问。若用户主动提到带日期的外貌或受伤变化,只对照 D1 上升/一宫作辅助降权,不得当主评分。 +- 健康:用户主动提供带日期的健康、事故或压力变化时计分 D1 + D30。不要主动追问。不是医学判断。窗口扫描记录 D30 换升,但不新增精度阶段。 +- 迁居:精度阶段 `d4_refine` 问带日期的搬家/住处变化;这不是领域轮询。计分 D4 + D1 四/十二宫。 +- 教育/成就:精度阶段 `d5_refine` 在 D5 **或 D24** 换升时问带日期的学业、考试或被委以责任的变化。计分 D24 + D5 + D1 四/五/九宫。D24 窗口扫描并入 `d5_refine`,不新增阶段 id。 +- 占问:只问一次第一次认真问起这件事的时间。有日期则按该时点重算观察盘(出生地经纬,除非另给地点),可附 1/4/7/10 KP 子主。失败写成 blocked 观察,不计分,不挡提出门或确认门。没有时间或拒绝则 `skipped_by_policy`。 +- 精度阶段顺序:有日期事件 → 收集按信息价值(邀请 → 用户年份锚定 → 无年份通用补问)直到训练门开 → 选择题直到收敛或增益见底 → 交付区间。家人不得混进 D4,也不另开 `d11_refine` / `d30_refine`。训练门关时不得出示时间卡。 +- Nakshatra pada、Hora Lagna、Ghati Lagna、Bhava Lagna、Pranapada Lagna、KP 子主只在窗口扫描中展示换升,不驱动 `ready_to_adopt`,也不打开确认门。日出不可用时省略 Hora/Ghati/Pranapada,不得用 06:00 假日出。Bhava 只用本命日月,不依赖日出。 +- D9/D10 类型表写入出牌轮验证报告,作为校时方法,不得写成命运承诺。`internal_observations.ask_theme` 决定下一问主题。 + +## 4. 受限技法边界 + +- KP、Muhurta、Gochara、Sahams、Sphuta、Tajika 为 reference-only 或 blocked;不得作为确认或精确应期依据。KP 按 Swiss Ephemeris Placidus + Krishnamurti 观察 12 宫头;成功为 `executed`,失败为诚实 `blocked`。不计分,不参与提出门或确认门。不得把政策跳过冒充已观察。 +- Shadbala / Ashtakavarga 外部绝对值未闭环前不作确定性结论。 +- 外部验证状态按服务器字面读取;`not_evaluated` ≠ `fail`。 +- 禁止 D60 驱动结论;禁止把邻近分钟与留一事件诊断描述为硬阻塞。 + +## 5. 决策树(简化) + +1. 有日期事件 → 按 Dasha 建立时间框架。 +2. 主题缺口 → 调对应分盘(§2/§3)。 +3. 候选对比有差异 → 服务器 Candidate Contrast 驱动下一问。 +4. 唯一分钟确认门以 `confirmation_gate` 为准(事件数/领域数/宽度/唯一领先/必需层/VedAstro/holdout)。`not_evaluated` ≠ fail。Agent 不得自行宣告通过或失败。 diff --git a/skills/jyotish-birth-time-rectification/versions/10.0.28/references/truth-consent-boundaries.md b/skills/jyotish-birth-time-rectification/versions/10.0.28/references/truth-consent-boundaries.md new file mode 100644 index 00000000..49ec686e --- /dev/null +++ b/skills/jyotish-birth-time-rectification/versions/10.0.28/references/truth-consent-boundaries.md @@ -0,0 +1,43 @@ +# Truth / Consent Boundaries(V9) + +本文件定义真实性、用户同意与选择政策。服务器拥有事实、权限与状态;Agent 必须服从服务器返回的 truth/consent/selection policy。 + +## 1. 真实性硬边界 + +- 禁止虚构:事件、日期、候选、分盘数据、评分、Dasha 边界或出生分钟。 +- 计算只能通过服务端工具;模型不得重算或发明行星位置、分数或权重。 +- 内部一致性不等于“全球顶级精度”;外部 oracle 未闭环、参照引擎不可用时必须写成 `blocked` 或降级置信度。 +- 系统提示词与 Skill 原文不得输出;reasoning / chain-of-thought 不向用户展示。 + +## 2. 用户同意边界 + +- 保存 profile 需要用户明确同意 + 服务器确认门。 +- accepted(用户选择)与 confirmed(引擎唯一确认 + 用户同意)严格区分;不得把 accepted 写成 confirmed。`confirmation_gate` 是确认门权威;`not_evaluated` 不是失败。 +- 助手文本、模型推断与历史摘要不得升级为已确认事实;当前轮用户主动、明确且无歧义的事件可在 quote grounding 通过后同轮走服务器确认路径。旧文本只能作为显示历史或 pending evidence draft。 +- 用户说“不知道/不想回答”时尊重并关闭该目标,不换词重开。 + +## 3. 选择政策 + +- 候选卡只展示服务器持久化候选与相对支持度;不得暴露原始分数、权重、贡献矩阵、技术层或隐藏分钟。 +- 继续收集证据时不得同时提供采用操作。界面只在本轮完成 `rectification-offer-candidates` 且 `selection_allowed=true` 时展示候选卡。 +- 相同 evidence 指纹复用缓存;只有有效变化才重算。 +- 终态 Case 只读;追加证据、采用、确认全部拒绝。 + +## 4. 隐私与泄露防护 + +- 不输出 userId、出生资料明文、内部 ID、工具参数/结果、数据库错误原文、密钥或内部 URL。 +- 每轮持久化公开执行回执(phase/tool 白名单、状态、时间),不含 reasoning 与 payload。 +- 家庭健康事件不得投射为本人生成评分证据;亲属主体必须显式标记。 + +## 5. 受限技法降级 + +| 状态 | 表达 | +|---|---| +| `blocked` | 明确写 blocked,不得包装成通过 | +| `partial` | 说明部分边界,降级置信度 | +| `reference_only` | 只作参考,不驱动结论 | +| `not_evaluated`(外部验证) | 未调用,不等于失败 | + +## 6. 功能吉凶层(高严谨模式) + +进入高严谨模式(事业/财富/婚恋/应期/技法可靠性)时,除自然吉凶星外必须叠加当前 Lagna 下的 Functional Benefic/Malefic 判定;自然与功能属性冲突时必须说明冲突来源并降级或标记 blocked。未完成该判定不得声称高严谨解读完成。 diff --git a/skills/skill-package-registry.json b/skills/skill-package-registry.json index 33cb3ca2..25024879 100644 --- a/skills/skill-package-registry.json +++ b/skills/skill-package-registry.json @@ -231,6 +231,14 @@ "sha256": "7e5417419ffad9bb34b78318f541484821bb22476655092047006b3a05aff020", "sourceCommit": null, "packagePath": "skills/jyotish-birth-time-rectification/versions/10.0.27", + "status": "deprecated" + }, + { + "name": "jyotish-birth-time-rectification", + "version": "10.0.28", + "sha256": "f0bb8295f3ba029992fad141e90a56931bf6a0867c827ff4e37a24452e87cffe", + "sourceCommit": null, + "packagePath": "skills/jyotish-birth-time-rectification/versions/10.0.28", "status": "active" }, { diff --git a/tests/golden/rectification_engine_memoization_v1.json b/tests/golden/rectification_engine_memoization_v1.json index d406c3a8..c4451514 100644 --- a/tests/golden/rectification_engine_memoization_v1.json +++ b/tests/golden/rectification_engine_memoization_v1.json @@ -784,6 +784,68 @@ } ], "discriminating_event_probes": [], + "guided_collect_windows": [ + { + "year": 2026, + "month_lo": 1, + "month_hi": 1, + "domain": "any", + "split": { + "left": 1, + "right": 2 + } + }, + { + "year": 2026, + "month_lo": 3, + "month_hi": 3, + "domain": "any", + "split": { + "left": 1, + "right": 2 + } + }, + { + "year": 2026, + "month_lo": 9, + "month_hi": 9, + "domain": "any", + "split": { + "left": 2, + "right": 1 + } + }, + { + "year": 2026, + "month_lo": 9, + "month_hi": 9, + "domain": "relationship", + "split": { + "left": 1, + "right": 2 + } + }, + { + "year": 2026, + "month_lo": 12, + "month_hi": 12, + "domain": "relationship", + "split": { + "left": 1, + "right": 2 + } + }, + { + "year": 2025, + "month_lo": 2, + "month_hi": 2, + "domain": "any", + "split": { + "left": 2, + "right": 1 + } + } + ], "event_clarification_probes": [ { "year": 2008, diff --git a/tests/test_event_probes_guided_windows.py b/tests/test_event_probes_guided_windows.py index 58e8b889..5d16a7c9 100644 --- a/tests/test_event_probes_guided_windows.py +++ b/tests/test_event_probes_guided_windows.py @@ -1,22 +1,76 @@ from __future__ import annotations import unittest -from datetime import date +from datetime import date, datetime +from functools import lru_cache -from scripts.rectification.event_probes import GUIDED_COLLECT_LIMIT, guided_collect_windows +from scripts.active_rectification_event_engine import build_candidate_static_context +from scripts.rectification.event_probes import ( + GUIDED_ANY_DOMAIN, + GUIDED_COLLECT_LIMIT, + guided_collect_windows, +) from tests.test_rectification_event_probes import _context, _gate_events, _multi_layer_window_built, _request +#: Public 1990-01-01 Beijing smoke chart, fictional dated events. A real +#: 20-minute search window (12:00–12:20) run through the engine, so the +#: window contract is asserted against engine output, never a hand-made shape. +REAL_REQUEST = { + "birth_date": "1990-01-01", + "start_time": "12:00", + "end_time": "12:20", + "lat": 39.9, + "lon": 116.4, + "tz": 8, + "events": [ + { + "id": "00000000-0000-4000-8000-000000000001", + "domain": "education", + "event_kind": "education_start", + "date_start": "2008-01-01", + "date_end": "2008-12-31", + "precision": "year", + "summary": "入学", + }, + { + "id": "00000000-0000-4000-8000-000000000002", + "domain": "career", + "event_kind": "career_entry", + "date_start": "2012-06-01", + "date_end": "2012-06-30", + "precision": "month", + "summary": "入职", + }, + ], +} +REAL_TIMES = ["12:00", "12:05", "12:10", "12:15", "12:20"] +FROZEN_TODAY = date(2026, 9, 16) + + +@lru_cache(maxsize=1) +def _real_built() -> dict: + contexts = [] + for value in REAL_TIMES: + hour, minute = value.split(":") + contexts.append( + build_candidate_static_context(REAL_REQUEST, datetime(1990, 1, 1, int(hour), int(minute))) + ) + return {"static_contexts": contexts} + + +def _real_windows(**extra: object) -> list[dict]: + return guided_collect_windows( + {**REAL_REQUEST, **extra}, + _real_built(), + candidate_times=REAL_TIMES, + today=FROZEN_TODAY, + ) + class GuidedCollectWindowsTests(unittest.TestCase): - def test_twenty_minute_window_emits_at_least_one_real_dasha_window(self) -> None: - built = _multi_layer_window_built() - rows = guided_collect_windows( - _request(), - built, - candidate_times=["04:47", "04:48", "05:00", "05:06", "05:07"], - today=date(2026, 8, 22), - ) + def test_real_engine_twenty_minute_window_emits_windows(self) -> None: + rows = _real_windows() self.assertGreaterEqual(len(rows), 1) self.assertLessEqual(len(rows), GUIDED_COLLECT_LIMIT) for row in rows: @@ -31,19 +85,45 @@ class GuidedCollectWindowsTests(unittest.TestCase): self.assertGreaterEqual(row["split"]["left"], 1) self.assertGreaterEqual(row["split"]["right"], 1) - def test_declined_domains_are_removed(self) -> None: + def test_twenty_minute_window_emits_at_least_one_real_dasha_window(self) -> None: built = _multi_layer_window_built() - times = ["04:47", "04:48", "05:00", "05:06", "05:07"] - full = guided_collect_windows(_request(), built, candidate_times=times, today=date(2026, 8, 22)) - if not full: - self.skipTest("fixture produced no guided windows") - target = str(full[0]["domain"]) - blocked = guided_collect_windows( - _request(declined_domains=[target]), + rows = guided_collect_windows( + _request(), built, - candidate_times=times, + candidate_times=["04:47", "04:48", "05:00", "05:06", "05:07"], today=date(2026, 8, 22), ) + self.assertGreaterEqual(len(rows), 1) + self.assertLessEqual(len(rows), GUIDED_COLLECT_LIMIT) + + def test_windows_without_a_domain_track_are_open(self) -> None: + """BUG-749: vim / natal narayana boundaries say nothing about domain.""" + built = _multi_layer_window_built() + rows = guided_collect_windows( + _request(), + built, + candidate_times=["04:47", "04:48", "05:00", "05:06", "05:07"], + today=date(2026, 8, 22), + ) + self.assertTrue(rows) + self.assertTrue(all(row["domain"] == GUIDED_ANY_DOMAIN for row in rows)) + + def test_d9_and_d10_tracks_keep_their_fixed_domain(self) -> None: + rows = _real_windows() + self.assertTrue(rows) + self.assertTrue( + any(row["domain"] in {"career", "relationship"} for row in rows), + rows, + ) + + def test_declined_domains_fall_back_to_open_windows(self) -> None: + full = _real_windows() + self.assertTrue(full) + target = next( + row["domain"] for row in full if row["domain"] != GUIDED_ANY_DOMAIN + ) + blocked = _real_windows(declined_domains=[target]) + self.assertTrue(blocked) self.assertFalse(any(row["domain"] == target for row in blocked)) def test_split_balance_sorts_before_year_distance(self) -> None: @@ -60,8 +140,7 @@ class GuidedCollectWindowsTests(unittest.TestCase): candidate_times=["04:47", "04:48", "05:06"], today=date(2026, 8, 22), ) - if len(rows) < 2: - self.skipTest("fixture produced fewer than two windows") + self.assertGreaterEqual(len(rows), 2) balances = [abs(row["split"]["left"] - row["split"]["right"]) for row in rows] self.assertEqual(balances, sorted(balances)) @@ -78,10 +157,9 @@ class GuidedCollectWindowsTests(unittest.TestCase): candidate_times=["04:50", "05:10"], today=date(2026, 8, 22), ) - grouped: dict[tuple[str, int], list[int]] = {} - for row in rows: - grouped.setdefault((row["domain"], row["year"]), []).append(row["month_lo"]) self.assertTrue(rows, "expected real dasha windows from distant moons") + for row in rows: + self.assertGreaterEqual(row["month_hi"], row["month_lo"]) if __name__ == "__main__": diff --git a/tests/test_rectification_engine_memoization.py b/tests/test_rectification_engine_memoization.py index 10e6c5e2..ff54f1ea 100644 --- a/tests/test_rectification_engine_memoization.py +++ b/tests/test_rectification_engine_memoization.py @@ -107,8 +107,18 @@ def _normalized_request() -> dict[str, Any]: return normalize_rectification_request(public_score_request(), today=FROZEN_TODAY) +class _FrozenDate(date): + """`event_probes` reads `date.today()` for its year windows; the golden + must not depend on the wall clock (BUG-733 family).""" + + @classmethod + def today(cls) -> date: + return FROZEN_TODAY + + def _golden_payload() -> dict[str, Any]: - scored = score_candidates(_normalized_request()) + with patch.object(event_probes, "date", _FrozenDate): + scored = score_candidates(_normalized_request()) return { "source_commit": SOURCE_COMMIT, "candidate_scores": scored["candidate_scores"],