feat(rectification): 少问几道就出卡,交付卡以范围为主(D1–D4)

- D1 引导窗口题每个校正最多 2 道(前端 GUIDED_WINDOW_CASE_LIMIT;引擎
  GUIDED_COLLECT_LIMIT 未动:event_probes.py 属冻结评分身份,改它需重新冻结)
- D2 七条定向线与跳过线重问问完即出卡,没问到的引导窗口不再挡卡,出卡后也不再挂窗口题
- D3 卡头加副标题「最可能 HH:MM」
- D4 前两列相差 ≥5 个百分点才显示相对可能性,否则一句「这几个时刻目前区分不开……」
- 离线回放 scripts/research/fewer_probes_card_replay.py:真值不降、宽度中位 ±1 分钟、提问 11.4→7.8
- Skill 10.0.29 → 10.0.30;DESIGN / VOICE / CHANGELOG / PROGRESS / 真机清单

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-26 14:27:12 +08:00
co-authored by Claude Opus 5.5
parent 7ddce2c96a
commit 4e6e8d87ef
48 changed files with 7885 additions and 54 deletions
+7 -3
View File
@@ -306,7 +306,7 @@ The birth-time rectification session is the consultation transcript plus a house
| `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled |
| `typed-pending` | the live row reads “收到,正在对照你的档案…” from the frame the answer is sent, then follows `turn.progress`: “正在记下这件事…” → “正在重新对照盘面…” → “正在准备下一个问题…” (BUG-1047). A running tool step shows the same stage line; finished steps keep their done labels. None of it survives the settle | enabled (typing queues), stop visible |
| `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 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 |
| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a caption under the title. A subtitle “最可能 HH:MM” (the representative minute) sits under the title (D3, 2026-09-26). The card appears once the refresh, the skip retry and the seven targeted kinds have been asked, or the reader said there is nothing more to add. Guided boundary windows are asked when they come up (at most two per Case, D1 2026-09-26) but an unasked window no longer holds the card (D2 2026-09-26). 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 月之间,有没有什么事,比如<two or three still-open kinds>?” — 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 |
@@ -326,7 +326,7 @@ The birth-time rectification session is the consultation transcript plus a house
- **Accessibility:** native radio inputs remain focusable, every conditional field has a persistent label, status text uses live regions, and the complete flow is keyboard operable.
- **Motion:** source-dependent fields enter with the existing 180ms opacity/vertical reveal; reduced-motion removes the translation.
- **Life-event evidence:** after deterministic questionnaire completion, render three structured event rows by default and allow up to six. Each row uses a domain select, a precision select, and a matching year/month/day control; free-form descriptions are not part of scoring.
- **Candidate result:** keep the reported range, candidate interval, and active-time status visually separate. Delivery shows one range card: title “目前范围 HH:MM–HH:MM(对照了 N 件经历)”, a caption “还能再收窄:如果记得 …” under the title while collect lines remain open, up to three compare columns (time, relative likelihood, D9/D10/nakshatra traits, event-fit counts, next-12-month windows, “更像这个”), and the representative-minute boundary. When the top two columns are within 3 percentage points and the seven collect lines are closed, the card body (not the caption) invites one more dated event of any kind, with examples outside those lines. Collect copy “现在还剩 HH:MM–HH:MM 里 N 个候选” uses the same `credible_range` as the timeline “目前范围”, not the first/last active candidate minute. An eight-method report sits in a closed `<details>` fold. Support numbers stay on the house board. Low confidence keeps evidence editing open; medium offers save or add evidence; high uses a separate confirmation action and never labels a column as the true birth time.
- **Candidate result:** keep the reported range, candidate interval, and active-time status visually separate. Delivery shows one range card: title “目前范围 HH:MM–HH:MM(对照了 N 件经历)”, a caption “还能再收窄:如果记得 …” under the title while collect lines remain open, a subtitle “最可能 HH:MM”, up to three compare columns (time, D9/D10/nakshatra traits, event-fit counts, next-12-month windows, “更像这个”; “相对可能性 N%” on every column only when the first column leads the second by ≥ 5 points, otherwise no numbers and one card line “这几个时刻目前区分不开,补一件带年月的经历能帮助分开。”), and the representative-minute boundary. When the top two columns are within 3 percentage points and the seven collect lines are closed, the card body (not the caption) invites one more dated event of any kind, with examples outside those lines. Collect copy “现在还剩 HH:MM–HH:MM 里 N 个候选” uses the same `credible_range` as the timeline “目前范围”, not the first/last active candidate minute. An eight-method report sits in a closed `<details>` fold. Support numbers stay on the house board. Low confidence keeps evidence editing open; medium offers save or add evidence; high uses a separate confirmation action and never labels a column as the true birth time.
- **Evidence accessibility:** every row keeps visible labels, validation errors use live regions, add/remove controls retain 44px targets, and scoring/confirmation loading states disable duplicate submission without hiding the existing evidence.
- **One-question guide:** the guided journey renders only the persisted `nextAction` and one server-selected question. A deterministic question is visible immediately; Agent wording may replace it without changing the question identity, domain, precision request, progress, or permissions. The composer explicitly permits an approximate year. Spoken collect does not render skip chips; typing 「没有」 still declines the domain and 「记不清」 still skips it. Stop on spoken collect is not a composer button: `CHOICE_STOP_LABEL` (“先这样,先看当前范围”) stays on choice cards, and generating turns keep “停止回答”. The readonly range line is a status sentence, not a stop control. Discriminator cards fold “为什么问这题” under the stem. Hovering or selecting an option does not reveal an `answer_impact` time line. The method sentence (`vargaSentence`) lives in the expanded activity timeline, not in the spoken bubble. The composer has no `rectification-step-state` status sentence and no `rectification-composer-meta`.
- **Draft review:** natural-language answers become one inline review card. The evidence domain is read-only and uses its Chinese label; precision controls which exact year, month, or day input is available. Incomplete drafts keep edit and skip paths visible, while confirmation is disabled until the structured date is valid. Status and errors use polite or assertive live regions without clearing the persisted journey.
@@ -824,7 +824,11 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
**相同的句子只写一次。** 两列或三列共有的 D9 / D10 / 月宿性格句提到卡片顶部(「三个时间的事业盘都在巨蟹座:做事以照顾人为主」)。列内只留有差别的句子;某列没有差别句就不显示性格块,不写「同上」。单列不提升共享句。
每列 = 时间 + 相对可能性 + 至多三行 + 「更像这个」。列内不再有 `<h3>`。经历对照由服务端拼成一行:「8 件经历里 7 件对得上,最不合的是 2024 年 5 月那段感情」。未来窗一行:「下一个值得留意的时段:2027 年 3 月前后(事业)」;算过但没有窗写「未来一年没有明显的时段」;`by_time` 缺键写灰字「这一分钟还没对照」,不得回退成 `0 · 0 · 0` 或把「没算」说成「没有窗」。用户可见文案不得出现「强相关 / 有关联 / 弱关联」。
**卡头以范围为主(D3,2026-09-26)。** 主标题仍是「目前范围 HH:MM–HH:MM(对照了 N 件经历)」(`<strong>`,`--type-title-sm`),紧跟一行副标题「最可能 HH:MM」(`.rectification-range-delivery__most-likely`,`--type-body-md`、次级墨色、等宽数字),取 `range_delivery.representative_time`。副标题只是代表分钟的人话,不是确认;卡底代表分钟边界句照旧。
**百分比只在差距明显时出现(D4,2026-09-26)。** 前两列「相对可能性」相差 ≥ 5 个百分点(`RANGE_DELIVERY_PERCENT_MIN_GAP`)时每列照旧写「相对可能性 N%」;否则三列都不写数字,卡上(共享性格句之上)一句「这几个时刻目前区分不开,补一件带年月的经历能帮助分开。」(`.rectification-range-delivery__indistinct`,`--type-body-md`、正文墨色)。只有一列时没有第二名可比,不写数字也不写这句。排序、列数、性格 / 经历 / 未来窗三行不变。理由:每件事约 11.5 分是窗口内恒定项、约 2.1 分随分钟变,比例分数天然挤在一起(26% / 26% / 20%),差距不明显的数字只会被读成「分出了高下」。
每列 = 时间 + (差距 ≥ 5 时)相对可能性 + 至多三行 + 「更像这个」。列内不再有 `<h3>`。经历对照由服务端拼成一行:「8 件经历里 7 件对得上,最不合的是 2024 年 5 月那段感情」。未来窗一行:「下一个值得留意的时段:2027 年 3 月前后(事业)」;算过但没有窗写「未来一年没有明显的时段」;`by_time` 缺键写灰字「这一分钟还没对照」,不得回退成 `0 · 0 · 0` 或把「没算」说成「没有窗」。用户可见文案不得出现「强相关 / 有关联 / 弱关联」。
卡顶保留范围与经历数;卡底边界句保留;「查看验证报告」默认收起。不预标「排盘用」。卡上没有「再答两道参考题」入口。风格参考题只在出卡前收集,出卡即结算。`tie_break_available` 仍由服务端决定要不要先问,不再驱动任何按钮。点「更像这个」走现有 accept RPC。采用过程中整张卡留在原处;已采用列按钮禁用。有活题时「更像这个」置灰。收尾句跟卡片同一列。
+4 -3
View File
@@ -105,8 +105,9 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 当前可信区间是 05:00–05:07,代表分钟 05:00。代表分钟只是代表性候选,不是已确认的唯一出生分钟。 | 已经从最初的 30 分钟收到 05:00–05:07 这 7 分钟。代表分钟是 05:00,这只是代表性候选,不是已确认的唯一出生分钟。 | 同一边界语义,带进度,少法务腔。 |
| 剩下的题分不开当前候选。更站得住的范围是 05:15,代表分钟 05:15。 | 再问下去也分不出更准的时间了。眼下更站得住的是 05:15。 | 单分钟不要把范围和代表分钟念两遍;「当前候选」是内部词。 |
| 可以从下面的时间里选一个采用。 / 下面的时间可以先用着。 | 我按你说的经历认真分析过了,下面是这次的结果。 | 这是认真分析后的结果,不是随便先用着;采用仍不是确认。区间交付卡必须就在这句下面。 |
| 相对支持度 16 · 采用此时间 | 一行至多三列。相同性格句只写一次。每列:相对可能性、有差别的性格句、人话经历对照、下一个时段,点「更像这个」。缺数据写灰字「这一分钟还没对照」。不预标「排盘用」。 | 交付对象是区间里并排的候选分钟,不是四张卡上的支持度数字。 |
| 已答6轮,再问下去也分不开04:53和05:00,没有年份的分盘题也不再问……两者相对支持度都是16。 | 再问下去也分不开 04:53 和 05:00。这两个时间按现有信息分不开。范围和代表分钟照常说。卡上三列仍写「相对可能性 %」。 | 旁白不得念内部计分词。卡上的「相对可能性」是 09-08 定稿,不要动。 |
| 相对支持度 16 · 采用此时间 | 卡头主标题是范围「目前范围 05:00–05:15(对照了 N 件经历)」,副标题「最可能 HH:MM」(代表分钟)。一行至多三列。相同性格句只写一次。每列:有差别的性格句、人话经历对照、下一个时段,点「更像这个」;第一名比第二名高 5 个百分点及以上时,每列再写「相对可能性 N%」。缺数据写灰字「这一分钟还没对照」。不预标「排盘用」。 | 交付对象是范围和区间里并排的候选分钟,不是支持度数字。 |
| 相对可能性 26% / 26% / 20%(前两名差不到 5 个百分点) | 不写任何数字,卡上一句:「这几个时刻目前区分不开,补一件带年月的经历能帮助分开。」 | 差距不明显的百分比只会让人误以为分出了高下(2026-09-26 产品决定,取代 09-08「三列都写相对可能性」的定稿)。排序与三列内容不变。 |
| 已答6轮,再问下去也分不开04:53和05:00,没有年份的分盘题也不再问……两者相对支持度都是16。 | 再问下去也分不开 04:53 和 05:00。这两个时间按现有信息分不开。范围和代表分钟照常说。 | 旁白不得念内部计分词。卡上的数字按上一行的 5 个百分点规则显示,旁白不念百分比。 |
| 候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。 | 每列用服务器拼好的一行时段;没有窗就写「未来一年没有明显的时段」;没算出来写「这一分钟还没对照」。 | 窗口按候选分钟各算,模型不写。不得把「没算」说成「没有窗」。 |
| 「再补一件经历」后卡片消失、输入框上方空白 | 需要补经历时继续在输入框说下一件;交付卡不再提供「再补一件经历」按钮。 | 交付卡只负责选分钟。 |
| 采用后会拿盘外核对来验证。 / 已采用 04:53 · 范围 … / 改选 / 之后新建对话即按此时间排盘。 | 没有核对题:已采用 04:53,新建对话即按这个时间排盘。有核对题:前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。 | 采用状态在该列按钮上;整张卡留着。收尾句跟卡片同一列,不单独贴左,也不回输入框上方。 |
@@ -122,7 +123,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
| 停止后出现红色告警条 | 已停止,已生成的内容保留;本次不会扣点。 | 停止是用户动作,不是出错。 |
| 打字回答发出后一直是「正在处理… / 正在分析…」,半分钟没有变化 | 发出即「收到,正在对照你的档案…」,随后按阶段换成「正在记下这件事…」「正在重新对照盘面…」「正在准备下一个问题…」。 | 发出后 300 ms 内要有一句确定的话;阶段由真实动作推动,只在活动行里,正文与历史里都没有(BUG-1047)。 |
| 也可以再说一件你记得大概时间的事。 / 还差带月份的经历,领域不限。 | 训练门开后给区间卡,并写「如果还记得……范围还能再收一截」。材料不够时只写记下了哪几件、再来一件不是这类的、至少两个具体例子;输入框占位「再说一件带年月的事」。 | 训练门未开时永远给结果或精确缺口;不说「领域不限」「做不了」。交付后采集线关完的邀请见下一行。 |
| 能问的都问完了 / 你要是还记得确切哪一天的事,不限领域,说出来我接着算 | 还有题可问时:「现在还剩 04:51–05:11 里 5 个候选,再对照几件经历会更准」,下面是系统点名的题。答「有」之后是口述「大概哪年几月?」,在输入框打字回答,不再出「哪一类事 + 发生年月」的卡。题问完了或用户说「没有了」,就出目前范围卡。 | 有题就继续引导;年月阶段直接打字。没题了照常给结果,不写「未达门槛」。不承诺「再补几件就能定到分钟」。 |
| 能问的都问完了 / 你要是还记得确切哪一天的事,不限领域,说出来我接着算 | 还有题可问时:「现在还剩 04:51–05:11 里 5 个候选,再对照几件经历会更准」,下面是系统点名的题。答「有」之后是口述「大概哪年几月?」,在输入框打字回答,不再出「哪一类事 + 发生年月」的卡。七条线和跳过线的重问问完了(按时间段点名的引导题一次校正最多两道),或用户说「没有了」,就出目前范围卡。 | 有题就继续引导;年月阶段直接打字。没题了照常给结果,不写「未达门槛」,也不为凑门槛再追问。不承诺「再补几件就能定到分钟」。 |
| 2018 年 3 到 5 月之间,有没有入职、换工作或职责变重?(引擎其实不知道是哪一类) | 「2018 年 3 到 5 月之间,有没有什么事,比如搬家或开始长期住外地、开始认真关系、分手或结婚?」只有 D9 / D10 那两条轨道才点名领域。 | 边界只说得出「哪段时间」,说不出「哪一类事」。例子最多三个,只列用户还没拒答、也还没说过的领域。 |
| (录入卡)哪一类事 + 发生年月 | 答「有」后只剩口述「大概哪年几月?」,在输入框打字。 | 多余入口删除;年月阶段不再有第二套录入。账本仍记用户原话,不得复用题干例子列表。 |
| 填报出生时间 05:10 / 与你的出生时间相差 7 分钟 | 医院记录:「出生记录时间 05:10」。家人记得:「你填的大概时间 05:10」「与你填的大概时间相差 7 分钟」。只知道时段:「你给的时间段」。 | 推算值不得称作「你的出生时间」。医院记录与目前范围不一致时只并列差值,不站队。 |
+14
View File
@@ -3516,6 +3516,20 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-range-delivery__accept:disabled {
cursor: default;
}
/* D3 (2026-09-26): representative minute as the subtitle under the range title. */
.rectification-candidates-heading .rectification-range-delivery__most-likely {
color: var(--color-ink-secondary);
font-size: var(--type-body-md);
font-variant-numeric: tabular-nums;
line-height: 1.5;
}
/* D4 (2026-09-26): replaces the per-column numbers when the top two are < 5 points apart. */
.rectification-range-delivery__indistinct {
margin: 0;
color: var(--color-ink);
font-size: var(--type-body-md);
line-height: 1.65;
}
.rectification-range-delivery__invite {
margin: 0;
color: var(--color-ink);
@@ -6,6 +6,8 @@ import {
RECTIFICATION_USER_COPY,
REPRESENTATIVE_MINUTE_DISCLAIMER,
rangeDeliveryEventCopy,
rangeDeliveryMostLikely,
rangeDeliveryShowsPercents,
} from "@/lib/rectification-agentic/user-copy";
import type { RangeDeliveryProjection } from "@/lib/rectification-agentic/v9/divergence-panel";
import {
@@ -51,11 +53,21 @@ export function RectificationRangeDelivery({
// it actually does — swap the charting clock — instead of the neutral 「更像这个」.
const recordConflict = delivery?.record_conflict ?? null;
const caption = delivery?.narrow_hint ?? null;
// D3: the range is the title; the representative minute is the subtitle.
const mostLikely = delivery?.representative_time ?? result.representativeTime;
// D4: per-column numbers only when the first column leads by ≥ 5 points.
const showPercents = rangeDeliveryShowsPercents(columns);
const indistinct = columns.length >= 2 && !showPercents;
return (
<section className="rectification-candidates rectification-range-delivery" aria-label="生时校正区间">
<div className="rectification-candidates-heading">
<strong>{title}</strong>
{mostLikely ? (
<span className="rectification-range-delivery__most-likely">
{rangeDeliveryMostLikely(mostLikely)}
</span>
) : null}
</div>
{caption ? (
<p className="rectification-range-delivery__narrow">{caption}</p>
@@ -71,6 +83,11 @@ export function RectificationRangeDelivery({
{delivery?.tie_break_note ? (
<p className="rectification-range-delivery__narrow">{delivery.tie_break_note}</p>
) : null}
{indistinct ? (
<p className="rectification-range-delivery__indistinct">
{RECTIFICATION_USER_COPY.rangeDeliveryIndistinct}
</p>
) : null}
{sharedTraits.length > 0 ? (
<ul className="rectification-range-delivery__shared">
{sharedTraits.map((line) => (
@@ -93,11 +110,13 @@ export function RectificationRangeDelivery({
>
<div className="rectification-range-delivery__time">{candidateDate ? `${candidateDate}${relativeDay} ${column.time}` : column.time}</div>
{candidateDate ? <p className="rectification-range-delivery__muted">采用后,排盘使用这一日期和时间;原填报日期保留。</p> : null}
<p className="rectification-range-delivery__likelihood">
{RECTIFICATION_USER_COPY.rangeDeliveryRelativeLikelihood}
{" "}
{column.probability_percent}%
</p>
{showPercents ? (
<p className="rectification-range-delivery__likelihood">
{RECTIFICATION_USER_COPY.rangeDeliveryRelativeLikelihood}
{" "}
{column.probability_percent}%
</p>
) : null}
{columnHasTraits(column) ? (
<div className="rectification-range-delivery__block">
{column.traits.d9 ? <p>{column.traits.d9}</p> : null}
@@ -239,7 +239,12 @@ export type DecideRectificationInput = Readonly<{
targetedCollectExhausted?: boolean;
/** D1: width ≤ 10, top-two display gap > 3, not an exact first-place tie. */
precisionGateMet?: boolean;
/** Guided window / skip-retry / uncovered-domain pool is empty. */
/**
* The lines that may hold the card are asked out: skip-retry and the
* targeted seven (production also requires the engine refresh). Since D2
* (2026-09-26) unasked guided boundary windows are not part of this — see
* `cardHoldingLinesExhausted`.
*/
guidedCollectExhausted?: boolean;
/** Opening search window from `case.candidateRange`. Omit in helper/unit paths. */
openingCandidateRange?: readonly [string, string] | null;
@@ -660,8 +665,11 @@ function stillNeedNarrowing(input: DecideRectificationInput): boolean {
/**
* 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
* `everyLineAsked` is the skip retry, the engine refresh, and the targeted
* seven. Product 2026-09-26 D2: guided boundary windows (at most two per
* Case, `GUIDED_WINDOW_CASE_LIMIT`) are asked when they come up, but no longer hold the card — once
* the targeted seven and their one re-ask are asked the card goes out with
* the gate unmet. 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
@@ -165,6 +165,7 @@ export const RECTIFICATION_USER_COPY = {
rangeDeliveryTitle: "目前范围",
rangeDeliveryMoreLikeThis: "更像这个",
rangeDeliveryRelativeLikelihood: "相对可能性",
rangeDeliveryIndistinct: "这几个时刻目前区分不开,补一件带年月的经历能帮助分开。",
rangeDeliveryNoWindow: "未来一年没有明显的时段",
rangeDeliveryNotCompared: "这一分钟还没对照",
rangeDeliveryAdopting: "正在采用…",
@@ -187,6 +188,28 @@ export function rangeDeliveryTopTwoTied(
<= RANGE_DELIVERY_TIE_PERCENT;
}
/**
* D4 (product 2026-09-26): per-column relative likelihood is shown only when
* the first column leads the second by at least this many percentage points.
* Below it the numbers carry no discrimination (the in-window constant part of
* every event score is ~5x the part that moves with the minute), so the card
* shows no numbers and one `rangeDeliveryIndistinct` sentence instead.
*/
export const RANGE_DELIVERY_PERCENT_MIN_GAP = 5;
export function rangeDeliveryShowsPercents(
columns: readonly { probability_percent: number }[],
): boolean {
if (columns.length < 2) return false;
const ranked = columns.map((column) => column.probability_percent).sort((left, right) => right - left);
return ranked[0]! - ranked[1]! >= RANGE_DELIVERY_PERCENT_MIN_GAP;
}
/** D3 (product 2026-09-26): subtitle under the range title. */
export function rangeDeliveryMostLikely(time: string): string {
return `最可能 ${time}`;
}
export const RANGE_DELIVERY_DOMAIN_LABEL: Readonly<Record<string, string>> = {
education: "学业",
career: "事业",
@@ -551,6 +574,8 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.rangeDeliveryTitle,
RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis,
RECTIFICATION_USER_COPY.rangeDeliveryRelativeLikelihood,
RECTIFICATION_USER_COPY.rangeDeliveryIndistinct,
rangeDeliveryMostLikely("05:07"),
RECTIFICATION_USER_COPY.rangeDeliveryNoWindow,
RECTIFICATION_USER_COPY.rangeDeliveryNotCompared,
RECTIFICATION_USER_COPY.rangeDeliveryAdopting,
@@ -90,4 +90,5 @@ export const MAX_RESUMABLE_CASES_PER_USER = 1;
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
export const RECTIFICATION_SKILL_VERSION = "10.0.29";
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
export const RECTIFICATION_SKILL_VERSION = "10.0.30";
@@ -1033,6 +1033,30 @@ function windowAlreadyAsked(
});
}
/**
* D1 (product 2026-09-26): at most two guided boundary windows per Case.
* Enforced here, not by lowering the engine's `GUIDED_COLLECT_LIMIT` (6, in
* `scripts/rectification/event_probes.py`): that file is part of the frozen
* scoring identity (`scripts/research/sealed_holdout_rerun.py`
* `PRODUCTION_FILES`), and any byte change there needs a research re-freeze
* this change was not authorised for (BUG-1047 anti-recurrence). The receipt
* keeps its ranked windows; the Case asks the first two it has not asked, and
* a new receipt after an answer cannot add a third.
*/
export const GUIDED_WINDOW_CASE_LIMIT = 2;
function askedGuidedWindowCount(topics: readonly CollectionTopic[]): number {
const asked = new Set<string>();
for (const topic of topics) {
const status = topicStatus(topic);
if (status === "active" || !status) continue;
const ref = parseGuidedWindowQuestionId(topicQuestionId(topic));
if (!ref || ref.stage !== "existence") continue;
asked.add(`${ref.year}:${ref.monthLo}:${ref.monthHi}`);
}
return asked.size;
}
export function guidedWindowPool(
windows: readonly GuidedCollectWindow[],
evidence: readonly CollectionEvidence[],
@@ -1043,6 +1067,7 @@ export function guidedWindowPool(
): CollectionPoolItem[] {
if (pendingTargetedYearDomain(declinedTopics, evidence)) return [];
if (pendingGuidedYearWindow(declinedTopics, evidence)) return [];
if (askedGuidedWindowCount(declinedTopics) >= GUIDED_WINDOW_CASE_LIMIT) return [];
const declined = collectDeclinedKinds(declinedTopics);
const covered = coveredCollectKinds(evidence);
const openDomains = COLLECT_KIND_ORDER.filter((kind) => (
@@ -1274,6 +1299,36 @@ export function guidedCollectExhausted(
);
}
/**
* D2 (product 2026-09-26): the lines that may still hold the delivery card
* are the targeted seven and their one re-ask (plus a year follow-up already
* in progress). Guided boundary windows are still asked while they come up
* first (at most `GUIDED_WINDOW_CASE_LIMIT` = 2 per Case), but an unasked
* window no longer keeps the card back: offline replay shows they add
* questions without moving the delivered range
* (`docs/research/fewer_probes_card_replay_2026_09_26.json`).
*/
export function cardHoldingLinesExhausted(
remainingLayers: readonly string[],
evidence: readonly CollectionEvidence[],
declinedTopics: readonly CollectionTopic[] = [],
splitTimes?: readonly [string, string] | null,
candidateCount?: number,
): boolean {
if (pendingTargetedYearDomain(declinedTopics, evidence)) return false;
if (pendingGuidedYearWindow(declinedTopics, evidence)) return false;
if (guidedRetryPool(evidence, declinedTopics, splitTimes, candidateCount).length > 0) {
return false;
}
return targetedCollectExhausted(
remainingLayers,
evidence,
declinedTopics,
splitTimes,
candidateCount,
);
}
export const COLLECT_FLOW_BANNED_PHRASES = [
"任何领域",
"领域不限",
@@ -64,7 +64,7 @@ import {
remainingSplitLayers,
remainingSplitTimes,
targetedCollectExhausted,
guidedCollectExhausted,
cardHoldingLinesExhausted,
type GuidedCollectWindow,
} from "./collection-question-pool.ts";
import { evidenceLedgerFingerprint } from "./tool-service";
@@ -587,14 +587,15 @@ 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).
// F3/BUG-751: nothing is "asked out" while the engine refresh has never
// been tried (BUG-654 / BUG-656). D2 (2026-09-26): once the targeted seven
// and their one re-ask are asked, the card is delivered — unasked guided
// windows no longer hold it back for the precision gate.
guidedCollectExhausted: options?.guidedCollectExhausted
?? (refreshDone && guidedCollectExhausted(
?? (refreshDone && cardHoldingLinesExhausted(
remainingLayers,
dossier.evidence,
declined,
live.guidedCollectWindows,
live.remainingSplitTimes,
live.remainingCandidateCount,
)),
@@ -134,6 +134,7 @@ export { GENERIC_COLLECT_QUESTION };
import {
RECTIFICATION_TERMINATION_COPY,
decideRectification,
sessionOutcomeAllowsDelivery,
type HoldoutValidationStatus,
} from "../core/rectification-decision.ts";
import type { ConflictProbe } from "../core/types.ts";
@@ -3140,6 +3141,9 @@ export function buildMethodFollowupPlan(input: {
&& datedPoolEmpty
&& !input.tieBreakRequested
) {
// D2 (2026-09-26): once the card is out, unasked guided windows are not
// asked any more (they no longer hold the card, so they must not ride
// along under it either). Retry and targeted lines are unchanged.
const targeted = targetedCollectFollowup(
input.remainingLayers ?? [],
input.evidence,
@@ -3150,7 +3154,7 @@ export function buildMethodFollowupPlan(input: {
input.remainingSplitTimes,
input.remainingCandidateCount,
input.remainingCredibleRange,
input.guidedWindows ?? [],
sessionOutcomeAllowsDelivery(sessionOutcome) ? [] : input.guidedWindows ?? [],
);
if (targeted) next = targeted;
else if (pendingHoldout) next = makeFollowup(pendingHoldout, false);
@@ -465,7 +465,8 @@ test("skill version is 10.0.26 after the targeted-collect-cards bump", () => {
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.30");
});
test("revision 5 with uncovered relatives asks the dated family collect, not a yearless D12 card", () => {
@@ -382,7 +382,8 @@ test("holdout not_ready forbids unique-minute copy and still blocks confirm", as
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.30");
const accounting = fakeAccounting({
...receiptHandlers,
@@ -259,14 +259,16 @@ test("skill 10.0.26 forbids computing varga signs from transition times", () =>
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.30");
// 原值: "10.0.25"
// 原值: "10.0.26"
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: /^version: 10\.0\.28$/ / 新值: /^version: 10\.0\.29$/ / 原因: 年月阶段改口述并禁止追问本人
assert.match(skill, /^version: 10\.0\.29$/m);
// 原值: /^version: 10\.0\.29$/ / 新值: /^version: 10\.0\.30$/ / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.match(skill, /^version: 10\.0\.30$/m);
assert.match(skill, new RegExp(SKILL_SIGN_SENTENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(comparison, new RegExp(SKILL_SIGN_SENTENCE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
});
@@ -1456,7 +1456,8 @@ test("public tool surface stays at 14 and new cases bind 10.0.26", () => {
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.30");
const deprecated = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"10.0.2",
@@ -603,7 +603,8 @@ test("skill version is 10.0.26 after the targeted-collect-cards bump", () => {
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.30");
});
test("USER_COLLECT_QUESTION no longer has an other fallback", () => {
@@ -0,0 +1,301 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { INFERENCE_ALGORITHM_VERSION, type InferenceCandidate, type InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import { buildRangeDelivery } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import {
RANGE_DELIVERY_PERCENT_MIN_GAP,
RECTIFICATION_USER_COPY,
listUserVisibleCopy,
rangeDeliveryMostLikely,
rangeDeliveryShowsPercents,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
GUIDED_WINDOW_CASE_LIMIT,
cardHoldingLinesExhausted,
guidedCollectExhausted,
guidedWindowPool,
type CollectionEvidence,
type GuidedCollectWindow,
} from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
import { buildMethodFollowupPlan, targetedCollectFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { RectificationRangeDelivery } from "../src/components/rectification-range-delivery.tsx";
import type { RectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts";
// Fictional data only. Three columns inside 05:00–05:15.
function cand(time: string, probability: number): InferenceCandidate {
return {
id: time,
time,
cluster_range: [time, time],
prior_score: probability * 100,
posterior_score: probability * 100,
probability,
status: "active",
rank: 1,
strong_conflict_count: 0,
};
}
function inference(probabilities: readonly [number, number, number]): InferenceState {
return {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: "05:00-05:15:05:02,05:07,05:13",
revision: 1,
phase: "discrimination",
result_status: "credible_range",
range_start: "05:00",
range_end: "05:15",
candidates: [
cand("05:07", probabilities[0]),
cand("05:02", probabilities[1]),
cand("05:13", probabilities[2]),
],
events: [
{ id: "e1", domain: "career", year: 2018, precision: "month", usage: "training" },
{ id: "e2", domain: "education", year: 2012, precision: "month", usage: "training" },
],
probes: [],
answered_probes: [],
rounds: [],
entropy: 1,
representative_time: "05:07",
credible_range: ["05:00", "05:15"],
};
}
const PUBLIC = [
{ candidateId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", time: "05:07" },
{ candidateId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2", time: "05:02" },
{ candidateId: "cccccccc-cccc-4ccc-8ccc-ccccccccccc3", time: "05:13" },
];
function result(probabilities: readonly [number, number, number]): RectificationCandidateResult {
const delivery = buildRangeDelivery({
inference: inference(probabilities),
publicCandidates: PUBLIC,
credibleRange: ["05:00", "05:15"],
representativeTime: "05:07",
});
return {
resultId: "result-1",
candidates: delivery.columns.map((column, index) => ({
candidateId: column.candidate_id,
rank: index + 1,
time: column.time,
relativeSupport: 10,
tiedMinuteCount: 1,
})),
overallConfidence: "medium",
selectionAllowed: true,
canAdopt: true,
confirmationAllowed: false,
decisionReceipt: null,
representativeTime: delivery.representative_time,
selectedTime: null,
selectionKind: null,
houseTable: null,
houseTablesByTime: {},
natalRecast: null,
techniqueAudit: [],
windowTransitions: [],
eventDashaLedger: [],
dashaAgreement: null,
lagnaContrast: null,
nakshatraBoundary: null,
precisionStage: null,
oosBlindPrompts: [],
confirmationGate: { confirmation_allowed: false } as RectificationCandidateResult["confirmationGate"],
validated: false,
completionStatus: null,
sessionOutcome: "adopt_representative",
credibleRange: delivery.range,
rangeDelivery: delivery,
verificationReportMarkdown: null,
};
}
function render(probabilities: readonly [number, number, number]): string {
return renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result: result(probabilities),
acceptingCandidateId: null,
readonly: false,
onAccept: () => undefined,
}));
}
test("D4 threshold is 5 points between the first and second column", () => {
assert.equal(RANGE_DELIVERY_PERCENT_MIN_GAP, 5);
assert.equal(rangeDeliveryShowsPercents([{ probability_percent: 40 }, { probability_percent: 35 }]), true);
assert.equal(rangeDeliveryShowsPercents([{ probability_percent: 30 }, { probability_percent: 26 }]), false);
assert.equal(rangeDeliveryShowsPercents([
{ probability_percent: 26 },
{ probability_percent: 26 },
{ probability_percent: 20 },
]), false);
// Order-independent: the gap is first − second after ranking.
assert.equal(rangeDeliveryShowsPercents([{ probability_percent: 30 }, { probability_percent: 45 }]), true);
// One column has no second place to be clearly ahead of.
assert.equal(rangeDeliveryShowsPercents([{ probability_percent: 100 }]), false);
});
test("D3 range is the title and the representative minute is the subtitle", () => {
const html = render([0.45, 0.3, 0.25]);
assert.match(html, /<strong>目前范围 05:00–05:15(对照了 2 件经历)<\/strong>/);
assert.match(html, /rectification-range-delivery__most-likely">最可能 05:07</);
assert.ok(html.indexOf("最可能 05:07") < html.indexOf("rectification-range-delivery__columns"));
assert.equal(rangeDeliveryMostLikely("05:07"), "最可能 05:07");
});
test("D4 gap ≥ 5 shows every column's percentage and no indistinct line", () => {
const html = render([0.45, 0.3, 0.25]);
assert.match(html, /相对可能性 45%/);
assert.match(html, /相对可能性 30%/);
assert.match(html, /相对可能性 25%/);
assert.doesNotMatch(html, new RegExp(RECTIFICATION_USER_COPY.rangeDeliveryIndistinct));
});
test("D4 gap < 5 shows no numbers and one indistinct sentence; columns and order unchanged", () => {
const html = render([0.35, 0.33, 0.32]);
assert.doesNotMatch(html, /相对可能性/);
assert.doesNotMatch(html, /\d+%/);
assert.equal(html.split(RECTIFICATION_USER_COPY.rangeDeliveryIndistinct).length - 1, 1);
assert.equal(
RECTIFICATION_USER_COPY.rangeDeliveryIndistinct,
"这几个时刻目前区分不开,补一件带年月的经历能帮助分开。",
);
assert.equal([...html.matchAll(/<button\b/g)].length, 3);
const order = ["05:07", "05:02", "05:13"].map((time) => html.indexOf(`__time">${time}`));
assert.deepEqual([...order].sort((left, right) => left - right), order);
assert.ok(order.every((index) => index > 0));
// The subtitle stays: it names the representative minute, not a confidence.
assert.match(html, /最可能 05:07/);
});
test("D3/D4 copy is registered as user-visible copy", () => {
const visible = listUserVisibleCopy();
assert.ok(visible.includes(RECTIFICATION_USER_COPY.rangeDeliveryIndistinct));
assert.ok(visible.includes(rangeDeliveryMostLikely("05:07")));
const voice = readFileSync(new URL("../docs/VOICE.md", import.meta.url), "utf8");
assert.ok(voice.includes(RECTIFICATION_USER_COPY.rangeDeliveryIndistinct));
assert.ok(voice.includes("最可能 HH:MM"));
});
const FRESH_WINDOWS: readonly GuidedCollectWindow[] = [
{ year: 2019, month_lo: 4, month_hi: 6, domain: "any", split: { left: 2, right: 1 } },
{ year: 2015, month_lo: 9, month_hi: 9, domain: "any", split: { left: 1, right: 2 } },
];
function askedWindow(year: number, lo: number, hi: number, status = "declined") {
return {
questionId: `collect:guided:window:${year}:${lo}:${hi}:any`,
target_domain: "any",
status,
intent: "collect_method_evidence",
target_kind: "guided:window:any",
};
}
test("D1 at most two guided windows per Case even when a new receipt brings fresh ones", () => {
assert.equal(GUIDED_WINDOW_CASE_LIMIT, 2);
const oneAsked = [askedWindow(2021, 1, 3)];
assert.equal(guidedWindowPool(FRESH_WINDOWS, [], oneAsked).length, 2);
const twoAsked = [askedWindow(2021, 1, 3), askedWindow(2017, 5, 5)];
assert.equal(guidedWindowPool(FRESH_WINDOWS, [], twoAsked).length, 0);
// The same window re-asked under `:next` is still one window.
const sameTwice = [askedWindow(2021, 1, 3), { ...askedWindow(2021, 1, 3), questionId: "collect:guided:window:2021:1:3:any:next" }];
assert.equal(guidedWindowPool(FRESH_WINDOWS, [], sameTwice).length, 2);
// An active (being asked) window does not count as asked yet.
assert.equal(guidedWindowPool(FRESH_WINDOWS, [], [askedWindow(2021, 1, 3), askedWindow(2017, 5, 5, "active")]).length, 2);
// With the cap reached the next question falls through to the targeted lines.
const followup = targetedCollectFollowup(["d9"], [], twoAsked, ["05:00", "05:15"], 3, ["05:00", "05:15"], FRESH_WINDOWS);
assert.ok(followup?.collection_key?.startsWith("collect:targeted:"), followup?.collection_key);
});
const ALL_SEVEN: CollectionEvidence[] = [
{ status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2012-09-01", occurredTo: "2012-09-01" },
{ status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-07-01", occurredTo: "2018-07-01" },
{ status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2016-08-01", occurredTo: "2016-08-01" },
{ status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2021-08-01", occurredTo: "2021-08-01" },
{ status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2019-01-01", occurredTo: "2019-01-01" },
{ status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2020-03-01", occurredTo: "2020-03-01" },
{ status: "confirmed", domain: "health_pressure", datePrecision: "month", occurredFrom: "2022-04-01", occurredTo: "2022-04-01" },
];
const LAYERS = ["d9", "d10", "d4", "d5", "d7", "d2", "d30"];
test("D2 unasked guided windows no longer hold the card once the targeted seven are asked", () => {
// The whole guided pool still reports a window left…
assert.equal(guidedCollectExhausted(LAYERS, ALL_SEVEN, [], FRESH_WINDOWS, ["05:00", "05:15"], 3), false);
// …but the lines that may hold the card are asked out.
assert.equal(cardHoldingLinesExhausted(LAYERS, ALL_SEVEN, [], ["05:00", "05:15"], 3), true);
});
test("D2 the skip re-ask and a pending year answer still hold the card", () => {
const skipped = [{
questionId: "collect:targeted:family",
target_domain: "family",
status: "skipped",
intent: "collect_method_evidence",
target_kind: "targeted:family",
}];
const withoutFamily = ALL_SEVEN.filter((row) => row.domain !== "family");
assert.equal(cardHoldingLinesExhausted(LAYERS, withoutFamily, skipped, ["05:00", "05:15"], 3), false);
const yesOnWindow = [askedWindow(2019, 4, 6, "resolved")];
assert.equal(cardHoldingLinesExhausted(LAYERS, ALL_SEVEN, yesOnWindow, ["05:00", "05:15"], 3), false);
// Targeted lines still open: keep asking.
assert.equal(cardHoldingLinesExhausted(LAYERS, ALL_SEVEN.slice(0, 3), [], ["05:00", "05:15"], 3), false);
});
test("D2 gate unmet with every card-holding line asked delivers; D5 range rules unchanged", () => {
const decision = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: true,
trainingGateOpen: true,
candidateScores: [
{ time: "05:02", score: 13 },
{ time: "05:07", score: 13.4 },
{ time: "05:13", score: 13.1 },
],
holdoutValidation: "unavailable",
datedEventCount: 7,
datedDomainCount: 7,
discriminatorProbe: null,
targetedCollectExhausted: true,
refreshExhausted: true,
inferenceCredibleRange: ["05:00", "05:15"],
openingCandidateRange: ["04:45", "05:15"],
precisionGateMet: false,
guidedCollectExhausted: cardHoldingLinesExhausted(LAYERS, ALL_SEVEN, [], ["05:00", "05:15"], 3),
});
assert.equal(decision.canOfferRange, true);
assert.deepEqual(decision.credibleRange, ["05:00", "05:15"]);
assert.equal(decision.precisionGateMet, false);
assert.equal(decision.canConfirmExactMinute, false);
});
test("D2 a delivered card does not carry an unasked guided window as the next question", () => {
const base = {
evidence: ALL_SEVEN,
declinedTopics: [],
candidatesSeparated: false,
remainingLayers: LAYERS,
remainingSplitTimes: ["05:00", "05:15"] as const,
remainingCandidateCount: 3,
remainingCredibleRange: ["05:00", "05:15"] as const,
guidedWindows: FRESH_WINDOWS,
};
for (const sessionOutcome of ["completed_with_range", "provisional_range", "adopt_representative"] as const) {
const plan = buildMethodFollowupPlan({ ...base, sessionOutcome });
const key = plan.next_followup?.collection_key ?? "";
assert.equal(key.startsWith("collect:guided:window:"), false, `${sessionOutcome}: ${key}`);
}
// While still collecting, the same windows are asked (at most two per Case).
const collecting = buildMethodFollowupPlan({ ...base, sessionOutcome: "collect_evidence" });
assert.match(collecting.next_followup?.collection_key ?? "", /^collect:guided:window:/);
});
@@ -220,14 +220,16 @@ test("new-case skill identity is 10.0.26 and the prompt prefers batch ingest", (
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.30");
// 原值: "10.0.25"
// 原值: "10.0.26"
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: /^version: 10\.0\.28$/ / 新值: /^version: 10\.0\.29$/ / 原因: 年月阶段改口述并禁止追问本人
assert.match(skill, /^version: 10\.0\.29$/m);
// 原值: /^version: 10\.0\.29$/ / 新值: /^version: 10\.0\.30$/ / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.match(skill, /^version: 10\.0\.30$/m);
assert.match(skill, /不要对同一句用户消息里的多件事件逐条 propose\+confirm/);
assert.match(agentSource, /新事件走 rectification-record-evidence-batch/);
assert.doesNotMatch(agentSource, /分别调用 rectification-propose-evidence 和 rectification-confirm-evidence/);
@@ -191,7 +191,8 @@ test("skill version is 10.0.26 after the targeted-collect-cards bump", () => {
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.30");
});
test("nineteen-row ledger opens the training gate with four scoreable domains", () => {
@@ -431,7 +431,8 @@ test("skill version is 10.0.26 after the targeted-collect-cards bump", () => {
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.30");
});
test("pre-fix dual-exit constant is gone; range narration carries numbers and the disclaimer", () => {
@@ -464,7 +464,8 @@ test("skill version is 10.0.26", () => {
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.30");
});
test("two education events do not spawn birth-year reverse questions", () => {
@@ -101,7 +101,8 @@ test("skill version is 10.0.26 after the targeted-collect-cards bump", () => {
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.30");
});
test("cases current_question remains the submit contract, not a visual slot", () => {
@@ -197,7 +197,8 @@ 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");
// 原值: /^version: 10\.0\.28$/ / 新值: /^version: 10\.0\.29$/ / 原因: 年月阶段改口述并禁止追问本人
assert.match(skill, /^version: 10\.0\.29$/m);
// 原值: /^version: 10\.0\.29$/ / 新值: /^version: 10\.0\.30$/ / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.match(skill, /^version: 10\.0\.30$/m);
assert.match(skill, /已拒绝(没有发生过 \/ 这类事都没有过)的目标不得换词重问;跳过的按服务器计划最多重问一次/);
assert.match(strategy, /跳过的线按服务器计划最多换一种问法再问一次/);
});
@@ -104,7 +104,8 @@ test("agent pins the dedicated rectification skill and its fixed version", () =>
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.29"));
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.30"));
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");
@@ -114,7 +115,8 @@ test("agent pins the dedicated rectification skill and its fixed version", () =>
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.30");
});
test("step budgets are bounded per action with a hard ceiling", () => {
@@ -101,7 +101,8 @@ test("the active rectification skill pins the v10 identity and lives in the righ
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.30");
assert.match(skill, /^---\nname: jyotish-birth-time-rectification/m);
// 原值: "10.0.25"
// 原值: "10.0.26"
@@ -109,7 +110,8 @@ test("the active rectification skill pins the v10 identity and lives in the righ
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: /^version: 10\.0\.28$/ / 新值: /^version: 10\.0\.29$/ / 原因: 年月阶段改口述并禁止追问本人
assert.match(skill, /^version: 10\.0\.29$/m);
// 原值: /^version: 10\.0\.29$/ / 新值: /^version: 10\.0\.30$/ / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.match(skill, /^version: 10\.0\.30$/m);
assert.match(skill, /至多一个主问题且唯一来源:[\s\S]*不得自行提出、复述、改写或预告问题/);
for (const reference of references) {
const content = readFileSync(`${skillDirectory}/references/${reference}`, "utf8");
@@ -211,7 +211,8 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
should_start_opening: true,
// 原值: "10.0.25";新值: "10.0.26";原因: BUG-668 新案绑定现行 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
skill_version: "10.0.29",
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
skill_version: "10.0.30",
};
}
return null;
@@ -255,7 +256,8 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(response.skillVersion, "10.0.29");
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(response.skillVersion, "10.0.30");
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");
@@ -265,7 +267,8 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(openCall.args.p_skill_version, "10.0.29");
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(openCall.args.p_skill_version, "10.0.30");
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);
@@ -92,5 +92,6 @@ test("agent body cannot verbally accept a spoken birth window", () => {
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.30");
});
@@ -167,7 +167,8 @@ test("SCORE_DELTA stays ±2/±1 and yearless weight is half", () => {
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.29");
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.30");
});
test("D9 answer B moves scores by ±1 and does not count toward elimination", () => {
+5 -5
View File
@@ -85,11 +85,11 @@ test("checked-in registry verifies hashed product packages and leaves consult on
[
{
name: "jyotish-birth-time-rectification",
// 原值: 10.0.28 / f0bb8295…cffe
// 新值: 10.0.29 / 750a0d58…724c
// 原因: 年月阶段改口述、禁止追问本人
version: "10.0.29",
sha256: "750a0d58c6731c0dbdeb5c785cea1bf8a3f73aaf94a560a86397805b7037724c",
// 原值: 10.0.29 / 750a0d58…724c
// 新值: 10.0.30 / 926db1ab…3a13
// 原因: D2 出卡句(引导窗口题不挡出卡、一次校正最多两道)与 D4 卡上百分比规则写进 Skill
version: "10.0.30",
sha256: "926db1ab20e7990ca0585037b6595691a2db0da363b4ab433f2fb98b2d223a13",
},
{
name: "jyotish-personal-report",