diff --git a/PROGRESS-rectification-decision-authority-20260831.md b/PROGRESS-rectification-decision-authority-20260831.md new file mode 100644 index 00000000..e5d323fb --- /dev/null +++ b/PROGRESS-rectification-decision-authority-20260831.md @@ -0,0 +1,173 @@ +# 生时校正决策权威重构进度(2026-08-31) + +## 基线与交付边界 + +- 隔离 worktree:`/Users/jesse/Downloads/Copse/astrology/.worktrees/rectification-decision-authority-20260831` +- 分支:`codex/rectification-decision-authority-20260831` +- 基线:`origin/staging` @ `9aebd0474a089ed5bec1fc8e8432d0d85e93ffb6` +- 2026-08-31 交付前重新执行 `git fetch origin staging --prune`;本地基线与远端 `staging` 仍一致。 +- 本次只交付任务 0、A、B。任务 C、D 尚未开始;不部署,不提升 `main`,不声称真实 authenticated staging flow 已验证。 + +## 任务 0:不变量门控 + +实现前先加入五条不变量并运行,五条均红,且失败原因分别落在任务书列出的病灶: + +1. TypeScript 能力超过引擎 ceiling。 +2. 证据不足仍允许采用/选择。 +3. 候选并列或未分离仍允许采用。 +4. holdout unavailable 仍允许采用。 +5. public overlay 用本地字段覆盖引擎否决。 + +A+B 完成后五条全部转绿:`tests 5 / pass 5 / fail 0`。 + +## 任务 A:停止原因分类化 + +- 新增三类 `StopClass`:`keep_collecting`、`exhausted`、`user_stopped`。 +- `insufficient_dated_events` 与 `insufficient_domains` 只进入收集流程,不再进入 `completeWithRange()`。 +- 真正耗尽(并列第一、用户不确定度过高)仍可结束为 review-only range。 +- 用户主动停止改变的是“是否继续追问”,不是证据是否达标:允许交付区间,但不放开采用能力。 +- 空候选不再返回无停止原因的 fail-open 状态,而是归入证据不足、继续收集。 +- `decideNextAction()` 与 `decideRectification()` 同步演进,保留既有一致性断言。 + +## 任务 B:能力标志单一来源与引擎上限 + +- 新增严格四字段 `EngineCapabilityCeiling`: + - `acceptanceAllowed` + - `selectionAllowed` + - `proposeAllowed` + - `confirmationAllowed` +- `decisionReceipt` 四字段缺失、非 boolean 或字段关系非法时 fail-closed。raw dossier receipt 还会严格校验 `accept_allowed` / `confirm_allowed` alias 与 canonical 字段一致,并校验 `display_allowed` 不得与已允许 selection 的结果冲突。 +- `deliveryCapability()` 成为以下四字段的唯一计算点: + - `canAdopt` + - `selectionAllowed` + - `proposeAllowed` + - `canConfirmExactMinute` +- 本地证据、coverage、holdout、separation 门与引擎 ceiling 取交集;TypeScript 只能收紧,不能放宽。 +- `overlayPublicDecision()` 对四个门禁字段取本地结果与 receipt ceiling 的逻辑与,保留候选投影不一致时的 fail-closed。 +- `v9/engine-client.ts` 扩展返回四个引擎能力字段。 +- `v9/decision-from-dossier.ts` 三个调用点、`core/decide-next-action.ts`、`v9/method-followup.ts` 已逐一处理。 +- `method-followup.ts` 的 helper 不参与生产交付,只用于问题流程,因此显式传关闭 ceiling;流程走向仍可 collect/discriminate,不会因四个交付能力全 false 而静默。 +- `v9/answer-choice.ts` 的采用 narration 改为绑定最终能力 `can_offer_range && can_adopt`,避免 `ready_to_adopt` 丢失同回合采用载体。 +- `v9/turn-decision.ts` 继续写入 `decision.selectionAllowed`;该字段现在已是 `deliveryCapability()` 的最终结果,无另行判门。 +- `frontend/src/lib/rectification-agentic/rectification-agentic/session.ts` 在 `frontend/src` 下无生产 import,属于非活跃 legacy 路径,本轮不改。 +- `decideConversationalSession()` 仍未接入生产路径;既有“生产工具不得调用它”的测试约束未解除。 + +## B-1 grep 自证 + +以下六个分支函数内,四个能力字段均无 boolean 字面量赋值: + +```text +collect: OK +discriminate: OK +offerRangeWithoutAdopt: OK +holdoutValidation: OK +completeWithRange: OK +finish: OK +``` + +## 事故输入回归 + +输入: + +- 1 条日期证据 +- 1 个 domain +- 9 个候选分数:`12,12,12,12,11,11,10,10,10` +- `trainingGateOpen=false` +- `methodCoverageAll=false` +- `snapshotCurrent=true` +- 引擎 ceiling 四字段全 false + +重构前任务书记录: + +```json +{ + "canAdopt": true, + "selectionAllowed": true, + "nextAction": "complete_with_range", + "stopReason": "insufficient_dated_events" +} +``` + +当前输出: + +```json +{ + "canAdopt": false, + "selectionAllowed": false, + "proposeAllowed": false, + "canConfirmExactMinute": false, + "nextAction": "ask_fact_collection", + "sessionOutcome": "collect_evidence", + "stopReason": "insufficient_dated_events", + "canOfferRange": false +} +``` + +## 既有断言修改配额 + +按用户批准的新口径: + +- 错误行为组:`6 / 6` +- 修改的既有断言:`10 / 12` + +前三条先前未列明的断言为: + +1. `frontend/tests/rectification-convergence-budget.test.ts:172`:`canAdopt true → false`;病灶 3,候选未分开且预算耗尽只能交付 review-only range。 +2. `frontend/tests/rectification-convergence-budget.test.ts:412`:label `blocked → user_history_verification_required`;病灶 1,空候选必须分类为证据不足而不是无原因 fail-open。 +3. `frontend/tests/rectification-decide-next-action.test.ts:511`:`sessionOutcome adopt_representative → provisional_range`;病灶 4,holdout unavailable 不能进入采用态。 + +完整清单(按错误行为组): + +### 组 1:候选未分离却进入采用态(病灶 3) + +1. `frontend/tests/rectification-answer-choice.test.ts:608`:`nextAction offer_provisional_range → ready_to_adopt`。原 fixture 为 `16/14`,回答后仍未达到 separation 门却依赖错误采用行为;fixture 已收窄修正为刚好达门的 `18/14` 并通过 holdout,正确采用态是 `ready_to_adopt`。 +2. `frontend/tests/rectification-convergence-budget.test.ts:172`:`canAdopt true → false`。预算耗尽不等于候选已分开。 + +### 组 2:空候选缺少停止分类(病灶 1) + +3. `frontend/tests/rectification-convergence-budget.test.ts:412`:label `blocked → user_history_verification_required`。空候选应继续收集日期证据。 + +### 组 3:无 holdout 的可信区间被当作可采用(病灶 4) + +4. `frontend/tests/rectification-credible-range-projection.test.ts:146`:`nextAction ready_to_adopt → offer_provisional_range`。无 holdout 只能 review-only。 + +### 组 4:单候选但 holdout unavailable 被当作可采用(病灶 4) + +5. `frontend/tests/rectification-decide-next-action.test.ts:511`:`sessionOutcome adopt_representative → provisional_range`。单候选不覆盖独立核对门。 + +### 组 5:sticky holdout 失去日期精度后仍可采用(病灶 4) + +6. `frontend/tests/rectification-holdout-renderable.test.ts:151`:`nextAction ready_to_adopt → offer_provisional_range`。 +7. `frontend/tests/rectification-holdout-renderable.test.ts:153`:`sessionOutcome adopt_representative → provisional_range`。 +8. `frontend/tests/rectification-holdout-renderable.test.ts:157`:`canAdopt true → false`。 + +三条均由同一错误行为产生:sticky 只保留 holdout 身份,unknown precision 使其不可验证,必须 fail-closed。 + +### 组 6:用户停止绕过证据与 coverage 门(病灶 2) + +9. `frontend/tests/rectification-range-offer-deadend.test.ts:601`:`canAdopt true → false`。用户停止只允许展示区间。 +10. `frontend/tests/rectification-range-offer-deadend.test.ts:608`:`canAdopt true → false`。两条同域日期事件仍不满足独立交付采用门。 + +`frontend/tests/rectification-holdout-renderable.test.ts` 中 `passed holdout` 的 `canAdopt === true` 保持不变;只补全 receipt fixture。 + +## 未放宽的门槛 + +- `MIN_STANDALONE_DATED_EVENTS = 3` +- `MIN_STANDALONE_DATED_DOMAINS = 2` +- `MIN_SEPARATION_LEAD = 8` +- 4 事件 / 3 domain 的 exact-minute confirmation gate + +## 本地验收 + +- `./node_modules/.bin/tsx --test --test-name-pattern='^invariant [1-5]:' tests/rectification-decision-authority.test.ts`:`5/5` 通过。 +- `./node_modules/.bin/tsx --test tests/rectification-answer-choice.test.ts`:`27/27` 通过。 +- `./node_modules/.bin/tsx --test tests/rectification-*.test.ts`:`712/712` 通过,`fail=0`。 +- `./node_modules/.bin/tsc --noEmit`:exit 0。 +- `git diff --check`:exit 0。 +- `.gitea/workflows/**`:无改动。 + +## 尚未执行 + +- 任务 C:前端 selection card 的独立 receipt fail-closed。 +- 任务 D:问题槽单一文案源与 Skill `10.0.14` 五处同步升版。 +- staging 部署与真实 authenticated flow 验证。 diff --git a/frontend/src/lib/rectification-agentic/core/decide-next-action.ts b/frontend/src/lib/rectification-agentic/core/decide-next-action.ts index 7a3f5d33..1b5b932d 100644 --- a/frontend/src/lib/rectification-agentic/core/decide-next-action.ts +++ b/frontend/src/lib/rectification-agentic/core/decide-next-action.ts @@ -56,8 +56,12 @@ export function decideNextAction(input: DecideNextActionInput): RectificationNex holdoutValidation: input.holdoutValidation, accepted: input.accepted, inferenceCredibleRange: input.inferenceCredibleRange, - engineAcceptAllowed: input.selectionAllowed, - engineProposeAllowed: input.proposeAllowed, + engineCeiling: { + acceptanceAllowed: input.selectionAllowed === true, + selectionAllowed: input.selectionAllowed === true, + proposeAllowed: input.proposeAllowed === true, + confirmationAllowed: input.confirmationAllowed === true, + }, inferenceRounds: input.inferenceRounds, effectiveAnswerCount: input.effectiveAnswerCount, plateauRounds: input.plateauRounds, diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index cad493ff..fa4037c1 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -38,6 +38,61 @@ export type EvidenceStopReason = | "tied_first" | "user_uncertainty_too_high"; +export type StopClass = + | Readonly<{ kind: "keep_collecting"; reason: "insufficient_dated_events" | "insufficient_domains" }> + | Readonly<{ kind: "exhausted"; reason: "tied_first" | "user_uncertainty_too_high" }> + | Readonly<{ kind: "user_stopped" }>; + +export type EngineCapabilityCeiling = Readonly<{ + acceptanceAllowed: boolean; + selectionAllowed: boolean; + proposeAllowed: boolean; + confirmationAllowed: boolean; +}>; + +type DeliveryCapability = Readonly<{ + canAdopt: boolean; + selectionAllowed: boolean; + proposeAllowed: boolean; + canConfirmExactMinute: boolean; +}>; + +const CLOSED_ENGINE_CAPABILITY_CEILING: EngineCapabilityCeiling = { + acceptanceAllowed: false, + selectionAllowed: false, + proposeAllowed: false, + confirmationAllowed: false, +}; + +export function engineCapabilityCeilingFromReceipt(value: unknown): EngineCapabilityCeiling { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return CLOSED_ENGINE_CAPABILITY_CEILING; + } + const row = value as Readonly>; + const acceptanceAllowed = row.acceptance_allowed; + const selectionAllowed = row.selection_allowed; + const proposeAllowed = row.propose_allowed; + const confirmationAllowed = row.confirmation_allowed; + if ( + typeof acceptanceAllowed !== "boolean" + || typeof selectionAllowed !== "boolean" + || typeof proposeAllowed !== "boolean" + || typeof confirmationAllowed !== "boolean" + || acceptanceAllowed !== selectionAllowed + || (row.accept_allowed !== undefined + && (typeof row.accept_allowed !== "boolean" || row.accept_allowed !== selectionAllowed)) + || (row.confirm_allowed !== undefined + && (typeof row.confirm_allowed !== "boolean" || row.confirm_allowed !== confirmationAllowed)) + || (row.display_allowed !== undefined + && (typeof row.display_allowed !== "boolean" || (selectionAllowed && !row.display_allowed))) + || (proposeAllowed && !selectionAllowed) + || (confirmationAllowed && !selectionAllowed) + ) { + return CLOSED_ENGINE_CAPABILITY_CEILING; + } + return { acceptanceAllowed, selectionAllowed, proposeAllowed, confirmationAllowed }; +} + export const RECTIFICATION_TERMINATION_COPY = "当前最优结果是候选时间段,而不是已经确认的唯一出生分钟。临时代表时间仅用于下一轮验证与比较。"; /** Standalone range-delivery floor; the exact-minute confirmation gate remains 4/3. */ @@ -98,8 +153,7 @@ export type DecideRectificationInput = Readonly<{ holdoutValidation?: HoldoutValidationStatus; accepted?: boolean; inferenceCredibleRange?: readonly [string, string] | null; - engineAcceptAllowed?: boolean; - engineProposeAllowed?: boolean; + engineCeiling: EngineCapabilityCeiling; datedMethodCollectOpen?: boolean; /** Server-persisted inference counters; never infer these from chat text. */ inferenceRounds?: number; @@ -111,28 +165,57 @@ export type DecideRectificationInput = Readonly<{ userUncertaintyHigh?: boolean; }>; -function evidenceStopReason( +function classifyStop( input: DecideRectificationInput, separation: CandidateSeparation, -): EvidenceStopReason | null { - if (separation.ranked.length === 0) return null; +): StopClass | null { if ( input.datedEventCount !== undefined && input.datedEventCount < MIN_STANDALONE_DATED_EVENTS ) { - return "insufficient_dated_events"; + return { kind: "keep_collecting", reason: "insufficient_dated_events" }; } if ( input.datedDomainCount !== undefined && input.datedDomainCount < MIN_STANDALONE_DATED_DOMAINS ) { - return "insufficient_domains"; + return { kind: "keep_collecting", reason: "insufficient_domains" }; + } + if (separation.ranked.length === 0 && !input.discriminatorProbe) { + return { kind: "keep_collecting", reason: "insufficient_dated_events" }; + } + if (input.userStopped === true) return { kind: "user_stopped" }; + if (separation.tiedForFirst) return { kind: "exhausted", reason: "tied_first" }; + if (input.userUncertaintyHigh === true) { + return { kind: "exhausted", reason: "user_uncertainty_too_high" }; } - if (separation.tiedForFirst) return "tied_first"; - if (input.userUncertaintyHigh === true) return "user_uncertainty_too_high"; return null; } +function deliveryCapability(input: { + stopClass: StopClass | null; + separation: CandidateSeparation; + holdout: HoldoutValidationStatus; + engineCeiling: EngineCapabilityCeiling; + confirmationAllowed: boolean; + coverageBlocks: boolean; +}): DeliveryCapability { + const locallySelectable = input.separation.ranked.length > 0 + && !input.coverageBlocks + && input.stopClass?.kind !== "keep_collecting" + && input.stopClass?.kind !== "exhausted" + && input.separation.sufficient + && input.holdout === "passed"; + return { + canAdopt: locallySelectable && input.engineCeiling.acceptanceAllowed, + selectionAllowed: locallySelectable && input.engineCeiling.selectionAllowed, + proposeAllowed: locallySelectable && input.engineCeiling.proposeAllowed, + canConfirmExactMinute: locallySelectable + && input.confirmationAllowed + && input.engineCeiling.confirmationAllowed, + }; +} + export function decideRectification(input: DecideRectificationInput): RectificationDecision { const separation = evaluateCandidateSeparation(input.candidateScores); const probe = input.discriminatorProbe ?? null; @@ -144,9 +227,63 @@ export function decideRectification(input: DecideRectificationInput): Rectificat ?? (separation.representativeTime ? [separation.representativeTime, separation.representativeTime] as const : null); + const stopClass = classifyStop(input, separation); + const stopReason = stopClass && "reason" in stopClass ? stopClass.reason : null; + const canDiscriminateDespiteCoverage = Boolean(probe) + && !separation.sufficient + && input.trainingGateOpen !== false; + const coverageBlocks = (!input.methodCoverageAll && !canDiscriminateDespiteCoverage) + || input.trainingGateOpen === false; + const capability = deliveryCapability({ + stopClass, + separation, + holdout, + engineCeiling: input.engineCeiling, + confirmationAllowed, + coverageBlocks, + }); - const stopReason = evidenceStopReason(input, separation); - + if (input.snapshotCurrent === false) { + if (probe && !userStopped && input.trainingGateOpen !== false) { + return discriminateOrExhaust(input, separation, holdout, range, probe, capability, stopReason); + } + return collect(separation, holdout, range, probe, capability, stopReason); + } + if (userStopped && separation.ranked.length > 0) { + return completeWithRange(separation, holdout, range, "user_stopped", capability); + } + if (coverageBlocks) { + const engineOffers = input.engineCeiling.acceptanceAllowed + || input.engineCeiling.proposeAllowed; + if ( + stopClass?.kind !== "keep_collecting" + && input.trainingGateOpen !== false + && separation.ranked.length > 0 + && !probe + && engineOffers + && input.datedMethodCollectOpen !== true + ) { + return offerRangeWithoutAdopt(separation, holdout, range, capability); + } + return collect(separation, holdout, range, probe, capability, stopReason); + } + if (stopClass?.kind === "keep_collecting") { + return collect(separation, holdout, range, probe, capability, stopClass.reason); + } + if (stopClass?.kind === "exhausted" && stopClass.reason === "user_uncertainty_too_high") { + return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason); + } + if (!separation.sufficient) { + if (probe) { + return discriminateOrExhaust(input, separation, holdout, range, probe, capability, stopReason); + } + return stopClass?.kind === "exhausted" + ? completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason) + : completeWithRange(separation, holdout, range, "offer", capability); + } + if (stopClass?.kind === "exhausted") { + return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason); + } if (input.accepted) { return finish(confirmationAllowed ? "awaiting_confirmation" : "adopt_representative", { input, @@ -154,7 +291,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat holdout, range, probe: null, - canConfirmExactMinute: confirmationAllowed, + capability, }); } if (confirmationAllowed) { @@ -164,78 +301,20 @@ export function decideRectification(input: DecideRectificationInput): Rectificat holdout, range, probe: null, - canConfirmExactMinute: true, + capability, }); } - if (userStopped && input.candidateScores.length > 0) { - return completeWithRange(separation, holdout, range, "user_stopped"); - } - if (input.snapshotCurrent === false) { - if (probe && !userStopped && input.trainingGateOpen !== false) { - return discriminateOrExhaust( - input, - separation, - holdout, - range, - probe, - ); - } - if (!(userStopped && input.candidateScores.length > 0)) { - return collect(separation, holdout, range, probe); - } - } - if (stopReason) { - return completeWithRange(separation, holdout, range, "exhausted", stopReason); - } - const canDiscriminateDespiteCoverage = Boolean(probe) - && !separation.sufficient - && input.trainingGateOpen !== false; - const coverageBlocks = (!input.methodCoverageAll && !canDiscriminateDespiteCoverage) - || input.trainingGateOpen === false; - if (coverageBlocks) { - const trainingOpen = input.trainingGateOpen !== false; - const engineOffers = input.engineAcceptAllowed === true || input.engineProposeAllowed === true; - if ( - trainingOpen - && input.candidateScores.length > 0 - && !probe - && engineOffers - && input.datedMethodCollectOpen !== true - ) { - return offerRangeWithoutAdopt(separation, holdout, range); - } - return collect(separation, holdout, range, probe); - } - if (!separation.sufficient) { - if (probe && !userStopped) { - return discriminateOrExhaust(input, separation, holdout, range, probe); - } - return completeWithRange(separation, holdout, range, userStopped ? "user_stopped" : "offer"); - } - if (holdout === "not_started" && !userStopped) { - return holdoutValidation(separation, range); + if (holdout === "not_started") { + return holdoutValidation(separation, range, capability); } if (holdout === "failed") { - if (probe && !userStopped) { - return discriminateOrExhaust(input, separation, holdout, range, probe); + if (probe) { + return discriminateOrExhaust(input, separation, holdout, range, probe, capability); } - return completeWithRange(separation, holdout, range, "exhausted"); + return completeWithRange(separation, holdout, range, "exhausted", capability); } if (holdout === "unavailable") { - if (userStopped) { - return completeWithRange(separation, holdout, range, "user_stopped"); - } - return finish("adopt_representative", { - input, - separation, - holdout, - range, - probe: null, - canConfirmExactMinute: false, - }); - } - if (userStopped && holdout !== "passed") { - return completeWithRange(separation, holdout, range, "user_stopped"); + return offerRangeWithoutAdopt(separation, holdout, range, capability); } return finish("adopt_representative", { input, @@ -243,7 +322,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat holdout, range, probe: null, - canConfirmExactMinute: false, + capability, }); } @@ -259,11 +338,13 @@ function discriminateOrExhaust( holdout: HoldoutValidationStatus, range: readonly [string, string] | null, probe: CandidateDiscriminatorProbe, + capability: DeliveryCapability, + stopReason: EvidenceStopReason | null = null, ): RectificationDecision { if (budgetExhausted(input)) { - return completeWithRange(separation, holdout, range, "exhausted"); + return completeWithRange(separation, holdout, range, "exhausted", capability, stopReason); } - return discriminate(separation, holdout, range, probe); + return discriminate(separation, holdout, range, probe, capability, stopReason); } function collect( @@ -271,6 +352,8 @@ function collect( holdout: HoldoutValidationStatus, range: readonly [string, string] | null, probe: CandidateDiscriminatorProbe | null, + capability: DeliveryCapability, + stopReason: EvidenceStopReason | null = null, ): RectificationDecision { return { phase: "event_collection", @@ -278,10 +361,7 @@ function collect( sessionOutcome: "collect_evidence", resultStatus: "insufficient_evidence", canOfferRange: false, - canAdopt: false, - canConfirmExactMinute: false, - selectionAllowed: false, - proposeAllowed: false, + ...capability, precisionStage: "collect_events", activeFocusPolicy: "keep", completionStatus: null, @@ -292,6 +372,7 @@ function collect( probe, holdoutValidation: holdout, droppedProbes: [], + stopReason, }; } @@ -299,6 +380,7 @@ function offerRangeWithoutAdopt( separation: CandidateSeparation, holdout: HoldoutValidationStatus, range: readonly [string, string] | null, + capability: DeliveryCapability, ): RectificationDecision { return { phase: "discrimination", @@ -306,10 +388,7 @@ function offerRangeWithoutAdopt( sessionOutcome: "provisional_range", resultStatus: "insufficient_evidence", canOfferRange: true, - canAdopt: false, - canConfirmExactMinute: false, - selectionAllowed: false, - proposeAllowed: false, + ...capability, precisionStage: "theme_refine", activeFocusPolicy: "close", completionStatus: null, @@ -361,6 +440,8 @@ function discriminate( holdout: HoldoutValidationStatus, range: readonly [string, string] | null, probe: CandidateDiscriminatorProbe, + capability: DeliveryCapability, + stopReason: EvidenceStopReason | null = null, ): RectificationDecision { return { phase: "discrimination", @@ -368,10 +449,7 @@ function discriminate( sessionOutcome: "discriminate_candidates", resultStatus: "discriminating", canOfferRange: false, - canAdopt: false, - canConfirmExactMinute: false, - selectionAllowed: false, - proposeAllowed: false, + ...capability, precisionStage: "theme_refine", activeFocusPolicy: "keep", completionStatus: null, @@ -382,12 +460,14 @@ function discriminate( probe, holdoutValidation: holdout, droppedProbes: [], + ...(stopReason ? { stopReason } : {}), }; } function holdoutValidation( separation: CandidateSeparation, range: readonly [string, string] | null, + capability: DeliveryCapability, ): RectificationDecision { return { phase: "holdout_validation", @@ -395,10 +475,7 @@ function holdoutValidation( sessionOutcome: "validate_holdout", resultStatus: "discriminating", canOfferRange: false, - canAdopt: false, - canConfirmExactMinute: false, - selectionAllowed: false, - proposeAllowed: false, + ...capability, precisionStage: "theme_refine", activeFocusPolicy: "keep", completionStatus: null, @@ -417,6 +494,7 @@ function completeWithRange( holdout: HoldoutValidationStatus, range: readonly [string, string] | null, kind: "user_stopped" | "offer" | "exhausted", + capability: DeliveryCapability, stopReason: EvidenceStopReason | null = null, ): RectificationDecision { const userStopped = kind === "user_stopped"; @@ -433,10 +511,7 @@ function completeWithRange( sessionOutcome, resultStatus: "completed_with_range", canOfferRange: true, - canAdopt: true, - canConfirmExactMinute: false, - selectionAllowed: true, - proposeAllowed: true, + ...capability, precisionStage: "ready_to_adopt", activeFocusPolicy: "close", completionStatus: userStopped ? "provisional_range_user_stopped" : null, @@ -460,17 +535,17 @@ function finish( holdout: HoldoutValidationStatus; range: readonly [string, string] | null; probe: CandidateDiscriminatorProbe | null; - canConfirmExactMinute: boolean; + capability: DeliveryCapability; }, ): RectificationDecision { - const completionStatus: CompletionStatus | null = input.canConfirmExactMinute && input.input.accepted + const completionStatus: CompletionStatus | null = input.capability.canConfirmExactMinute && input.input.accepted ? "exact_minute_confirmed" : input.holdout === "passed" ? "validated_range" : input.input.userStopped === true ? "provisional_range_user_stopped" : null; - const kind = input.canConfirmExactMinute + const kind = input.capability.canConfirmExactMinute ? (input.input.accepted ? "exact_minute_confirmed" : "awaiting_confirmation") : input.holdout === "passed" ? "validated_range" @@ -483,10 +558,7 @@ function finish( ? "converged" : "completed_with_range", canOfferRange: true, - canAdopt: true, - canConfirmExactMinute: input.canConfirmExactMinute, - selectionAllowed: true, - proposeAllowed: true, + ...input.capability, precisionStage: "ready_to_adopt", activeFocusPolicy: "close", completionStatus, diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 951c0024..052c0990 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -679,7 +679,7 @@ ${nextInterview.hostNarration}` nextInterviewPersisted = true; } } - const adoptionNarration = nextAction.type === "offer_provisional_range" && nextAction.can_adopt + const adoptionNarration = nextAction.can_offer_range && nextAction.can_adopt ? `${nonConvergingRangeNarration({ credibleRange: nextAction.credible_range, representativeTime: nextAction.representative_time, 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 63f54e3b..f7871203 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -18,6 +18,7 @@ import { } from "../core/candidate-contrast-packet.ts"; import { decideRectification, + engineCapabilityCeilingFromReceipt, publicDecisionFields, type HoldoutValidationStatus, type RectificationDecision, @@ -522,9 +523,7 @@ export function decideFromDossier( holdoutValidation: holdoutStatusFromInference(inference, oosBlindPrompts), accepted: Boolean(dossier.case.acceptedTime), inferenceCredibleRange: inference?.credible_range ?? null, - engineAcceptAllowed: latest?.selectionAllowed === true - || latest?.decisionReceipt?.accept_allowed === true, - engineProposeAllowed: latest?.decisionReceipt?.propose_allowed === true, + engineCeiling: engineCapabilityCeilingFromReceipt(latest?.decisionReceipt ?? null), datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods), ...decisionBudget, ...evidenceStops, @@ -553,6 +552,7 @@ export function decideAfterInferenceChange(input: { trainingGateOpen: trainingScoreableGate(input.dossier.evidence).open, candidateScores: [], userStopped: input.userStopped, + engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null), ...decisionBudgetFromInference(null), ...evidenceStops, }); @@ -593,9 +593,7 @@ export function decideAfterInferenceChange(input: { inferenceCredibleRange: input.state.credible_range, userStopped: input.userStopped, accepted: Boolean(input.dossier.case.acceptedTime), - engineAcceptAllowed: input.dossier.latestResult?.selectionAllowed === true - || input.dossier.latestResult?.decisionReceipt?.accept_allowed === true, - engineProposeAllowed: input.dossier.latestResult?.decisionReceipt?.propose_allowed === true, + engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null), datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods), ...decisionBudgetFromInference(input.state), ...evidenceStops, @@ -616,9 +614,22 @@ export function overlayPublicDecision( const fields = publicDecisionFields(decision); const projection = authoritativeCandidateProjection(snapshot as DecisionDossier["latestResult"]); const inconsistent = !projection.consistent; + const ceiling = engineCapabilityCeilingFromReceipt( + (snapshot as DecisionDossier["latestResult"])?.decisionReceipt ?? null, + ); + const canAdopt = !inconsistent && fields.can_adopt && ceiling.acceptanceAllowed; + const selectionAllowed = !inconsistent && fields.selection_allowed && ceiling.selectionAllowed; + const proposeAllowed = !inconsistent && fields.propose_allowed && ceiling.proposeAllowed; + const canConfirmExactMinute = !inconsistent + && fields.can_confirm_exact_minute + && ceiling.confirmationAllowed; return { ...snapshot, ...fields, + can_adopt: canAdopt, + selection_allowed: selectionAllowed, + propose_allowed: proposeAllowed, + can_confirm_exact_minute: canConfirmExactMinute, ...(projection.fromInference ? { candidates: projection.candidates, @@ -628,8 +639,7 @@ export function overlayPublicDecision( credible_range: projection.credibleRange, } : {}), - ...(inconsistent ? { can_adopt: false, selection_allowed: false } : {}), - selectionAllowed: inconsistent ? false : fields.selection_allowed, + selectionAllowed, validated: fields.validated, completionStatus: fields.completion_status, }; diff --git a/frontend/src/lib/rectification-agentic/v9/engine-client.ts b/frontend/src/lib/rectification-agentic/v9/engine-client.ts index e6a3ffa1..c0a045a3 100644 --- a/frontend/src/lib/rectification-agentic/v9/engine-client.ts +++ b/frontend/src/lib/rectification-agentic/v9/engine-client.ts @@ -70,7 +70,9 @@ export type V9EngineScoreResult = Readonly<{ candidates: readonly V9EngineCandidate[]; overallConfidence: "low" | "medium" | "high"; marginPercent: number | null; + acceptanceAllowed: boolean; selectionAllowed: boolean; + proposeAllowed: boolean; confirmationAllowed: boolean; representativeCandidateId: string | null; representativeTime: string | null; @@ -237,7 +239,9 @@ type ParsedReceipt = Readonly<{ raw: V9DecisionReceipt; eventContractVersion: string; policyVersion: string; + acceptanceAllowed: boolean; selectionAllowed: boolean; + proposeAllowed: boolean; confirmationAllowed: boolean; representativeCandidateId: string | null; representativeTime: string | null; @@ -267,6 +271,7 @@ function readDecisionReceipt(value: unknown, candidates: readonly V9EngineCandid || typeof row.display_allowed !== "boolean" || typeof row.selection_allowed !== "boolean" || typeof row.acceptance_allowed !== "boolean" + || typeof row.propose_allowed !== "boolean" || typeof row.confirmation_allowed !== "boolean" || typeof row.accept_allowed !== "boolean" || typeof row.confirm_allowed !== "boolean" @@ -288,7 +293,6 @@ function readDecisionReceipt(value: unknown, candidates: readonly V9EngineCandid || (representativeCandidateId !== null && (!representative || representative.time !== representativeTime)) || (row.confirmation_allowed === true && row.selection_allowed !== true) || (row.selection_allowed === true && row.display_allowed !== true) - || (row.propose_allowed !== undefined && typeof row.propose_allowed !== "boolean") || (row.propose_allowed === true && row.selection_allowed !== true) || !questionContractVersionIsCompatible(row.question_contract_version ?? row.question_contract) ) { @@ -298,7 +302,9 @@ function readDecisionReceipt(value: unknown, candidates: readonly V9EngineCandid raw: row, eventContractVersion: EVENT_CONTRACT_VERSION, policyVersion: row.policy_version, + acceptanceAllowed: row.acceptance_allowed, selectionAllowed: row.selection_allowed, + proposeAllowed: row.propose_allowed, confirmationAllowed: row.confirmation_allowed, representativeCandidateId, representativeTime, @@ -527,7 +533,9 @@ export async function runV9CandidateScore(input: { candidates, overallConfidence: receipt.overallConfidence, marginPercent: receipt.marginPercent, + acceptanceAllowed: receipt.acceptanceAllowed, selectionAllowed: receipt.selectionAllowed, + proposeAllowed: receipt.proposeAllowed, confirmationAllowed: receipt.confirmationAllowed, representativeCandidateId: receipt.representativeCandidateId, representativeTime: receipt.representativeTime, diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index c9bb8fe8..deb0f6e8 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -1139,8 +1139,14 @@ export function decideConversationalSession(input: { : discriminatorFromFollowup(input.nextFollowup), holdoutValidation: input.holdoutValidation, accepted: input.accepted, - engineAcceptAllowed: input.selectionAllowed, - engineProposeAllowed: input.proposeAllowed, + // This helper is not used by the production V9 delivery path; keep its + // delivery ceiling explicit without coupling it to question selection. + engineCeiling: { + acceptanceAllowed: input.selectionAllowed === true, + selectionAllowed: input.selectionAllowed === true, + proposeAllowed: input.proposeAllowed === true, + confirmationAllowed: input.confirmationAllowed === true, + }, datedMethodCollectOpen: input.methods ? datedMethodCollectOpen(input.methods) : undefined, inferenceRounds: input.inferenceRounds, effectiveAnswerCount: input.effectiveAnswerCount, diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index e197a37c..cb50ce4c 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -26,7 +26,7 @@ import { userFacingRunFailure, isIncompleteRunBanner, } from "../src/lib/rectification-agentic/v9/run-diagnostic.ts"; -import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; import { RECTIFICATION_TERMINATION_COPY } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { parseV9CaseDossier, RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { @@ -425,11 +425,11 @@ function familyCollectDossier() { } function adoptionInference() { - return buildInferenceState({ + return applyHoldoutAnswer(buildInferenceState({ range_start: "04:45", range_end: "05:15", candidates: [ - { id: "05:00", time: "05:00", relative_support: 16 }, + { id: "05:00", time: "05:00", relative_support: 18 }, { id: "05:10", time: "05:10", relative_support: 14 }, ], events: [ @@ -439,7 +439,7 @@ function adoptionInference() { { id: "e4", domain: "relationship", year: 2024, precision: "day" }, ], probes: [RELOCATION_2015_PROBE], - }); + }), "yes"); } function adoptionDossier() { @@ -466,7 +466,13 @@ function adoptionDossier() { latestResult: candidateSnapshotFixture({ selectionAllowed: true, representativeTime: "05:00", - decisionReceipt: { inference_state: inference }, + decisionReceipt: { + acceptance_allowed: true, + selection_allowed: true, + propose_allowed: true, + confirmation_allowed: false, + inference_state: inference, + }, }), conversationSummary: conversationSummaryFixture({ declinedSkippedTopics: [{ target_domain: "family", status: "skipped" }], @@ -598,7 +604,8 @@ test("last structured choice emits the adoption range and persists the same narr optionId: "A", expectedRevision: adoptionInference().revision, }); - assert.equal(applied.nextAction.type, "offer_provisional_range"); + // 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, /当前可信区间是/); assert.match(applied.narration, /代表分钟/); diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index e721cace..8b4aa1fb 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -820,9 +820,11 @@ function liveCaseDossier(): DecisionDossier { })), representativeTime: state.representative_time, decisionReceipt: { + acceptance_allowed: true, accept_allowed: true, propose_allowed: true, selection_allowed: true, + confirmation_allowed: false, inference_state: state, }, }, diff --git a/frontend/tests/rectification-confirmation-gate.test.ts b/frontend/tests/rectification-confirmation-gate.test.ts index 9f390bfc..6996982b 100644 --- a/frontend/tests/rectification-confirmation-gate.test.ts +++ b/frontend/tests/rectification-confirmation-gate.test.ts @@ -19,6 +19,7 @@ import { import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts"; import { safeToolErrorCode, RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { + OPEN_ENGINE_CAPABILITY_CEILING, CASE_ID, CANDIDATE_ID, RESULT_ID, @@ -138,6 +139,7 @@ const PLATEAU = [ function collectDecision(candidates: readonly { time: string; relativeSupport: number }[]) { return decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, trainingGateOpen: false, candidateScores: candidates.map((item) => ({ time: item.time, score: item.relativeSupport })), diff --git a/frontend/tests/rectification-convergence-budget.test.ts b/frontend/tests/rectification-convergence-budget.test.ts index 10f851e4..6b7b3e4a 100644 --- a/frontend/tests/rectification-convergence-budget.test.ts +++ b/frontend/tests/rectification-convergence-budget.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts"; + import { EFFECTIVE_ANSWER_SAFETY_CAP } from "../src/lib/birth-time-dynamic-stop-policy.ts"; import { DEFAULT_MAX_DISCRIMINATION_ROUNDS, @@ -50,6 +52,7 @@ const PROBE: CandidateDiscriminatorProbe = { }; const BASE_INPUT = { + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: true, trainingGateOpen: true, candidateScores: [ @@ -165,7 +168,8 @@ test("exhausted discrimination still delivers the credible candidate range", () assert.equal(decision.resultStatus, "completed_with_range"); assert.equal(decision.sessionOutcome, "completed_with_range"); assert.equal(decision.canOfferRange, true); - assert.equal(decision.canAdopt, true); + // 原值是 true;候选未分开且区分预算耗尽只能交付 review-only range,不能采用。 + assert.equal(decision.canAdopt, false); assert.deepEqual(decision.credibleRange, ["05:00", "05:07"]); }); @@ -198,19 +202,24 @@ test("additional score evidence never widens the credible range", () => { assert.ok(width(after.credibleRange) <= width(before.credibleRange)); }); -test("evidence-state stops complete with a usable range and the fixed termination copy", () => { +test("insufficient standalone evidence keeps collecting", () => { + for (const item of [ + { reason: "insufficient_dated_events" as const, input: { datedEventCount: 2, datedDomainCount: 2 } }, + { reason: "insufficient_domains" as const, input: { datedEventCount: 3, datedDomainCount: 1 } }, + ]) { + const decision = decideRectification({ ...BASE_INPUT, ...item.input }); + assert.equal(decision.nextAction, "ask_fact_collection"); + assert.equal(decision.sessionOutcome, "collect_evidence"); + assert.equal(decision.canOfferRange, false); + assert.equal(decision.stopReason, item.reason); + } +}); + +test("exhausted evidence-state stops complete with a usable range and the fixed termination copy", () => { const cases: readonly Readonly<{ reason: EvidenceStopReason; input: Partial[0]>; }>[] = [ - { - reason: "insufficient_dated_events", - input: { datedEventCount: 2, datedDomainCount: 2 }, - }, - { - reason: "insufficient_domains", - input: { datedEventCount: 3, datedDomainCount: 1 }, - }, { reason: "tied_first", input: { @@ -221,6 +230,7 @@ test("evidence-state stops complete with a usable range and the fixed terminatio { time: "05:06", score: 34 }, { time: "05:07", score: 32 }, ], + discriminatorProbe: null, }, }, { @@ -393,11 +403,13 @@ test("dossier and post-inference decisions share the half-uncertain stop rule", test("rectification label ladder follows evidence state and explicit adapter support", () => { const blocked = decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, trainingGateOpen: false, candidateScores: [], }); - assert.equal(rectificationLabel({ decision: blocked }), "blocked"); + // 原值是 blocked;空候选现在被分类为证据不足,不能继续无原因地 fail-open。 + assert.equal(rectificationLabel({ decision: blocked }), "user_history_verification_required"); const stopped = decideRectification({ ...BASE_INPUT, diff --git a/frontend/tests/rectification-credible-range-projection.test.ts b/frontend/tests/rectification-credible-range-projection.test.ts index dbf59e2d..1dac685a 100644 --- a/frontend/tests/rectification-credible-range-projection.test.ts +++ b/frontend/tests/rectification-credible-range-projection.test.ts @@ -94,7 +94,13 @@ function producedSnapshot( candidates: persisted, representativeTime: state.representative_time, evidenceLedgerFingerprint: evidenceLedgerFingerprint(COVERAGE_EVIDENCE as never), - decisionReceipt: { inference_state: state }, + decisionReceipt: { + acceptance_allowed: true, + selection_allowed: true, + propose_allowed: true, + confirmation_allowed: false, + inference_state: state, + }, }, }; } @@ -128,7 +134,7 @@ test("producer 58/42 lead keeps both candidates and can close without opening th assert.notEqual(decision.sessionOutcome, "exact_minute_confirmed"); }); -test("producer 58/42 with no holdout is ready_to_adopt, not a unique minute", () => { +test("producer 58/42 with no holdout remains a review-only range", () => { const { latest } = producedSnapshot([ { time: "05:02", relativeSupport: 58 }, { time: "04:55", relativeSupport: 42 }, @@ -136,7 +142,9 @@ test("producer 58/42 with no holdout is ready_to_adopt, not a unique minute", () const decision = decideFromDossier(dossierFor(latest)); assert.equal(authoritativeCandidateProjection(latest).consistent, true); assert.equal(decision.separation.sufficient, true); - assert.equal(decision.nextAction, "ready_to_adopt"); + // 原值是 ready_to_adopt;没有 holdout 不能进入采用态,只能展示 review-only range。 + assert.equal(decision.nextAction, "offer_provisional_range"); + assert.equal(decision.canAdopt, false); assert.equal(decision.canConfirmExactMinute, false); assert.notEqual(decision.sessionOutcome, "exact_minute_confirmed"); }); diff --git a/frontend/tests/rectification-decide-next-action.test.ts b/frontend/tests/rectification-decide-next-action.test.ts index c13eb6fb..4fe8fb6c 100644 --- a/frontend/tests/rectification-decide-next-action.test.ts +++ b/frontend/tests/rectification-decide-next-action.test.ts @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts"; + import { askedEventProbeKeysFromLedgerEvidence, buildCandidateContrastPacket, @@ -128,12 +130,14 @@ test("unseparated scores still discriminate when training is open and a probe ex }); assert.equal(next.type, "ask_candidate_discriminator"); assert.equal(decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, trainingGateOpen: true, candidateScores: TIED, discriminatorProbe: CONTRAST_PROBE, }).sessionOutcome, "discriminate_candidates"); assert.equal(decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, trainingGateOpen: true, candidateScores: [], @@ -302,6 +306,7 @@ test("user stop skips remaining collection and holdout when candidates already e }); assert.equal(separated.type, "complete_with_range"); const stopped = decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, userStopped: true, candidateScores: [ @@ -364,6 +369,7 @@ test("discrimination waits for three training events after reserving holdout", ( test("holdout passed is a validated range, not a user-stop close", () => { const validated = decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: true, candidateScores: [ { time: "04:48", score: 58 }, @@ -473,6 +479,7 @@ test("stale snapshot keeps discrimination instead of returning to collection", ( }); assert.equal(next.type, "ask_candidate_discriminator"); assert.equal(decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: true, trainingGateOpen: true, snapshotCurrent: false, @@ -494,12 +501,15 @@ test("a single candidate is sole_candidate, not a parallel range", () => { assert.equal(separation.lead, 0); assert.deepEqual(separation.credibleRange, ["05:00"]); const decision = decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: true, trainingGateOpen: true, candidateScores: [{ time: "05:00", score: 80 }], holdoutValidation: "unavailable", }); - assert.equal(decision.sessionOutcome, "adopt_representative"); + // 原值是 adopt_representative;holdout unavailable 且用户未停止只能提供 review-only range。 + assert.equal(decision.sessionOutcome, "provisional_range"); + assert.equal(decision.canAdopt, false); assert.equal(decision.canConfirmExactMinute, false); assert.doesNotMatch( buildSkillVerificationReport({ diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index 877ae788..191ad7af 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -4,6 +4,7 @@ import test from "node:test"; import { decideRectification, + engineCapabilityCeilingFromReceipt, publicDecisionFields, } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts"; @@ -29,6 +30,182 @@ const SEPARATED = [ { time: "04:49", score: 42 }, ]; +type TestEngineCapabilityCeiling = Readonly<{ + acceptanceAllowed: boolean; + selectionAllowed: boolean; + proposeAllowed: boolean; + confirmationAllowed: boolean; +}>; + +const ENGINE_OPEN: TestEngineCapabilityCeiling = { + acceptanceAllowed: true, + selectionAllowed: true, + proposeAllowed: true, + confirmationAllowed: true, +}; + +function capabilityCombinations(): TestEngineCapabilityCeiling[] { + return Array.from({ length: 16 }, (_, bits) => ({ + acceptanceAllowed: Boolean(bits & 1), + selectionAllowed: Boolean(bits & 2), + proposeAllowed: Boolean(bits & 4), + confirmationAllowed: Boolean(bits & 8), + })); +} + +function decideWithEngineCeiling( + engineCeiling: TestEngineCapabilityCeiling, + overrides: Partial[0]> = {}, +) { + return decideRectification({ + methodCoverageAll: true, + trainingGateOpen: true, + snapshotCurrent: true, + candidateScores: SEPARATED, + holdoutValidation: "passed", + confirmationAllowed: true, + datedEventCount: 3, + datedDomainCount: 2, + ...overrides, + engineCeiling, + } as Parameters[0]); +} + +test("invariant 1: TypeScript delivery capabilities never exceed the engine ceiling", () => { + for (const ceiling of capabilityCombinations()) { + const decision = decideWithEngineCeiling(ceiling); + assert.equal(decision.canAdopt && !ceiling.acceptanceAllowed, false, JSON.stringify(ceiling)); + assert.equal(decision.selectionAllowed && !ceiling.selectionAllowed, false, JSON.stringify(ceiling)); + assert.equal(decision.proposeAllowed && !ceiling.proposeAllowed, false, JSON.stringify(ceiling)); + assert.equal(decision.canConfirmExactMinute && !ceiling.confirmationAllowed, false, JSON.stringify(ceiling)); + } +}); + +test("invariant 2: insufficient dated evidence never permits selection or adoption", () => { + for (const datedEventCount of [0, 2, 3]) { + for (const datedDomainCount of [0, 1, 2]) { + if (datedEventCount >= 3 && datedDomainCount >= 2) continue; + const decision = decideWithEngineCeiling(ENGINE_OPEN, { + confirmationAllowed: false, + datedEventCount, + datedDomainCount, + }); + assert.equal(decision.canAdopt, false, `${datedEventCount} events / ${datedDomainCount} domains`); + assert.equal(decision.selectionAllowed, false, `${datedEventCount} events / ${datedDomainCount} domains`); + } + } +}); + +test("invariant 3: tied leading candidates remain review-only when the user stops", () => { + const tiedScores = [ + { time: "04:48", score: 12 }, + { time: "04:49", score: 12 }, + { time: "04:50", score: 11 }, + ]; + const stopped = decideWithEngineCeiling(ENGINE_OPEN, { + candidateScores: tiedScores, + confirmationAllowed: false, + userStopped: true, + }); + assert.equal(stopped.completionStatus, "provisional_range_user_stopped"); + assert.equal(stopped.canAdopt, false); + + const continuing = decideWithEngineCeiling(ENGINE_OPEN, { + candidateScores: tiedScores, + confirmationAllowed: false, + userStopped: false, + }); + assert.equal(continuing.separation.tiedForFirst, true); + assert.equal(continuing.canAdopt, false); +}); + +test("invariant 4: unavailable holdout stays review-only whether or not the user stops", () => { + for (const userStopped of [false, true]) { + const decision = decideWithEngineCeiling(ENGINE_OPEN, { + confirmationAllowed: false, + holdoutValidation: "unavailable", + userStopped, + }); + assert.equal(decision.canAdopt, false, `userStopped=${userStopped}`); + } +}); + +test("raw engine receipt contradictions fail closed before delivery", () => { + const openReceipt = { + acceptance_allowed: true, + selection_allowed: true, + propose_allowed: true, + confirmation_allowed: false, + }; + const closed = { + acceptanceAllowed: false, + selectionAllowed: false, + proposeAllowed: false, + confirmationAllowed: false, + }; + for (const receipt of [ + { ...openReceipt, accept_allowed: false }, + { ...openReceipt, confirm_allowed: true }, + { ...openReceipt, display_allowed: false }, + { ...openReceipt, confirmation_allowed: undefined }, + ]) { + assert.deepEqual(engineCapabilityCeilingFromReceipt(receipt), closed); + } +}); + +test("invariant 5: public overlay intersects all delivery gates with the engine receipt", () => { + const decision = decideWithEngineCeiling(ENGINE_OPEN); + const candidates = [ + { candidateId: "c-0507", time: "05:07", rank: 1, relativeSupport: 13, tiedMinuteCount: 1 }, + { candidateId: "c-0500", time: "05:00", rank: 2, relativeSupport: 12, tiedMinuteCount: 1 }, + { candidateId: "c-0515", time: "05:15", rank: 3, relativeSupport: 10, tiedMinuteCount: 1 }, + ]; + const inferenceState = { + algorithm_version: "rectification-inference-v1", + candidate_set_id: "05:00-05:15:05:00,05:07,05:15", + revision: 2, + phase: "discrimination", + result_status: "discriminating", + range_start: "05:00", + range_end: "05:15", + candidates: [ + { id: "05:07", time: "05:07", cluster_range: ["05:07", "05:07"], prior_score: 13, posterior_score: 15, probability: 0.4, status: "active", rank: 2, strong_conflict_count: 0 }, + { id: "05:00", time: "05:00", cluster_range: ["05:00", "05:00"], prior_score: 12, posterior_score: 20, probability: 0.6, status: "active", rank: 1, strong_conflict_count: 0 }, + { id: "05:15", time: "05:15", cluster_range: ["05:15", "05:15"], prior_score: 10, posterior_score: 30, probability: 0, status: "eliminated", rank: 3, strong_conflict_count: 1 }, + ], + events: [], + probes: [], + answered_probes: [], + rounds: [], + entropy: 0.67, + representative_time: "05:00", + credible_range: ["05:00", "05:07"], + }; + const validEngineCeilings = capabilityCombinations().filter((ceiling) => ( + ceiling.acceptanceAllowed === ceiling.selectionAllowed + && (!ceiling.proposeAllowed || ceiling.selectionAllowed) + && (!ceiling.confirmationAllowed || ceiling.selectionAllowed) + )); + + for (const ceiling of validEngineCeilings) { + const overlaid = overlayPublicDecision({ + candidates, + representativeTime: "05:07", + decisionReceipt: { + acceptance_allowed: ceiling.acceptanceAllowed, + selection_allowed: ceiling.selectionAllowed, + propose_allowed: ceiling.proposeAllowed, + confirmation_allowed: ceiling.confirmationAllowed, + inference_state: inferenceState, + }, + }, decision); + assert.equal(overlaid.can_adopt, ceiling.acceptanceAllowed, JSON.stringify(ceiling)); + assert.equal(overlaid.selection_allowed, ceiling.selectionAllowed, JSON.stringify(ceiling)); + assert.equal(overlaid.propose_allowed, ceiling.proposeAllowed, JSON.stringify(ceiling)); + assert.equal(overlaid.can_confirm_exact_minute, ceiling.confirmationAllowed, JSON.stringify(ceiling)); + } +}); + function readSource(relative: string) { return readFileSync(new URL(relative, import.meta.url), "utf8"); } @@ -63,7 +240,7 @@ test("public decision fields are derived from decideRectification", () => { }, ]; for (const input of fixtures) { - const decision = decideRectification(input); + const decision = decideRectification({ ...input, engineCeiling: ENGINE_OPEN }); const fields = publicDecisionFields(decision); assert.deepEqual(fields, { type: decision.nextAction, @@ -518,6 +695,7 @@ test("career and relationship training still discriminates before family or occu test("public candidate cards follow the inference ranking and hide an inconsistent state", () => { const decision = decideRectification({ + engineCeiling: ENGINE_OPEN, methodCoverageAll: true, trainingGateOpen: true, candidateScores: SEPARATED, diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 4c004c35..17bfc9fc 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -30,6 +30,7 @@ import { resolveExactSkillPackage, } from "../src/lib/skill-package-registry.ts"; import { + OPEN_ENGINE_CAPABILITY_CEILING, CASE_ID, CANDIDATE_ID, FOCUS_ID, @@ -55,6 +56,7 @@ const UNIQUE_MINUTE_COPY = /±5 分钟确定性/; function collectDecision(candidates: readonly { time: string; relativeSupport?: number }[]) { return decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, trainingGateOpen: false, candidateScores: candidates.map((item) => ({ time: item.time, score: item.relativeSupport ?? 0 })), @@ -198,6 +200,7 @@ const ENGINE_SCORE = { display_allowed: true, selection_allowed: true, acceptance_allowed: true, + propose_allowed: true, confirmation_allowed: false, accept_allowed: true, confirm_allowed: false, @@ -856,6 +859,7 @@ test("read-case follows method plan and keeps D9/D10 type tables when SQL missin policy_version: "rectification-candidate-policy-v2", selection_allowed: true, acceptance_allowed: true, + propose_allowed: true, confirmation_allowed: false, representative_candidate_id: CANDIDATE_ID, overall_confidence: "medium", @@ -1373,6 +1377,15 @@ test("public tool surface stays at 14 and new cases bind 10.0.13", () => { selectionKind: null, algorithmVersion: "rectification-v5", decisionReceipt: { + acceptance_allowed: true, + selection_allowed: true, + propose_allowed: true, + confirmation_allowed: false, + inference_state: producedInferenceState(plateauCandidates.map((item) => ({ + id: item.candidateId, + time: item.time, + relative_support: item.relativeSupport, + }))), execution_ledger: [ { method: "vimshottari-dasha", status: "executed" }, { method: "narayana-dasha", status: "executed" }, @@ -1389,7 +1402,7 @@ test("public tool surface stays at 14 and new cases bind 10.0.13", () => { // The execution ledger does not prove same-range support or raw-result agreement. assert.equal(plateau.rectification_label, "manual_pattern_consensus"); assert.deepEqual(plateau.executed_methods, ["vimshottari-dasha", "narayana-dasha"]); - const completed = latestResultToolProjection({ + const collecting = latestResultToolProjection({ resultId: RESULT_ID, candidates: plateauCandidates, selectionAllowed: true, @@ -1405,6 +1418,7 @@ test("public tool surface stays at 14 and new cases bind 10.0.13", () => { ], }, }, decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, trainingGateOpen: false, datedEventCount: 2, @@ -1414,10 +1428,41 @@ test("public tool surface stays at 14 and new cases bind 10.0.13", () => { score: item.relativeSupport, })), })); - assert.equal(completed.rectification_label, "user_history_verification_required"); - assert.equal(completed.evidence_stop_reason, "insufficient_dated_events"); + assert.equal(collecting.rectification_label, "user_history_verification_required"); + assert.equal(collecting.evidence_stop_reason, "insufficient_dated_events"); + assert.deepEqual(collecting.executed_methods, ["vimshottari-dasha", "narayana-dasha"]); + + const exhaustedCandidates = plateauCandidates.map((item, index) => ({ + ...item, + relativeSupport: index < 2 ? 40 : 20, + })); + const completed = latestResultToolProjection({ + resultId: RESULT_ID, + candidates: exhaustedCandidates, + selectionAllowed: true, + confirmationAllowed: false, + representativeTime: "04:45", + selectedTime: null, + selectionKind: null, + algorithmVersion: "rectification-v5", + decisionReceipt: { + acceptance_allowed: true, + selection_allowed: true, + propose_allowed: true, + confirmation_allowed: false, + }, + }, decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, + methodCoverageAll: true, + trainingGateOpen: true, + datedEventCount: 3, + datedDomainCount: 2, + candidateScores: exhaustedCandidates.map((item) => ({ + time: item.time, + score: item.relativeSupport, + })), + })); assert.equal(completed.termination_copy, "当前最优结果是候选时间段,而不是已经确认的唯一出生分钟。临时代表时间仅用于下一轮验证与比较。"); - assert.deepEqual(completed.executed_methods, ["vimshottari-dasha", "narayana-dasha"]); const skill = readFileSync(new URL("../../skills/jyotish-birth-time-rectification/SKILL.md", import.meta.url), "utf8"); assert.match(skill, /method_followup_plan/); assert.match(skill, /感情 → 事业 → 家人 → 职业 → 占问/); @@ -1435,12 +1480,12 @@ test("public tool surface stays at 14 and new cases bind 10.0.13", () => { test("Mastra hides active candidates when the receipt range is corrupted", () => { const candidates = [ - { candidateId: CANDIDATE_ID, time: "05:00", rank: 1, relativeSupport: 20, tiedMinuteCount: 1 }, - { candidateId: SECOND_CANDIDATE_ID, time: "05:07", rank: 2, relativeSupport: 15, tiedMinuteCount: 1 }, + { candidateId: CANDIDATE_ID, time: "05:00", rank: 1, relativeSupport: 58, tiedMinuteCount: 1 }, + { candidateId: SECOND_CANDIDATE_ID, time: "05:07", rank: 2, relativeSupport: 42, tiedMinuteCount: 1 }, ]; const inferenceState = producedInferenceState([ - { id: CANDIDATE_ID, time: "05:00", relative_support: 20 }, - { id: SECOND_CANDIDATE_ID, time: "05:07", relative_support: 15 }, + { id: CANDIDATE_ID, time: "05:00", relative_support: 58 }, + { id: SECOND_CANDIDATE_ID, time: "05:07", relative_support: 42 }, ]); const latest = { resultId: RESULT_ID, @@ -1451,9 +1496,16 @@ test("Mastra hides active candidates when the receipt range is corrupted", () => selectedTime: null, selectionKind: null, algorithmVersion: "rectification-v5", - decisionReceipt: { inference_state: inferenceState }, + decisionReceipt: { + acceptance_allowed: true, + selection_allowed: true, + propose_allowed: true, + confirmation_allowed: false, + inference_state: inferenceState, + }, }; const session = decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: true, userStopped: true, candidateScores: candidates.map((item) => ({ time: item.time, score: item.relativeSupport })), @@ -1468,6 +1520,7 @@ test("Mastra hides active candidates when the receipt range is corrupted", () => const invalid = latestResultToolProjection({ ...latest, decisionReceipt: { + ...latest.decisionReceipt, inference_state: { ...inferenceState, credible_range: ["04:00", "04:10"] }, }, }, session); @@ -2896,11 +2949,15 @@ test("paused case with selection_allowed may offer the escape hatch", async () = ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ status: "paused", - evidence: [educationEvidence], + evidence: [educationEvidence, familyEvidence, careerEvidence], latestResult: candidateSnapshotFixture({ selectionAllowed: true, representativeTime: "04:48", - evidenceLedgerFingerprint: scoreableFingerprintForRawEvidence([educationEvidence]), + evidenceLedgerFingerprint: scoreableFingerprintForRawEvidence([ + educationEvidence, + familyEvidence, + careerEvidence, + ]), candidates: [ { candidate_id: CANDIDATE_ID, rank: 1, time: "04:48", relative_support: 58, tied_minute_count: 1 }, { candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "04:49", relative_support: 42, tied_minute_count: 2 }, diff --git a/frontend/tests/rectification-hidden-e2e.test.ts b/frontend/tests/rectification-hidden-e2e.test.ts index f1b94479..795ce96f 100644 --- a/frontend/tests/rectification-hidden-e2e.test.ts +++ b/frontend/tests/rectification-hidden-e2e.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts"; + import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; import { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; import { unionStillValidRange } from "../src/lib/rectification-agentic/core/credible-range.ts"; @@ -155,6 +157,7 @@ test("hidden case walks collection through holdout to a range or representative score: item.posterior_score, })); const afterAnswers = decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: true, candidateScores: scores, holdoutValidation: "not_started", @@ -166,6 +169,7 @@ test("hidden case walks collection through holdout to a range or representative assert.equal(holdoutPassed.holdout_passed, true); assert.deepEqual(posteriorMap(holdoutPassed.candidates), posteriorMap(answered.state.candidates)); const closed = decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: true, candidateScores: holdoutPassed.candidates.map((item) => ({ time: item.time, @@ -223,6 +227,7 @@ test("holdout failure returns to candidate discrimination", () => { }], }); const next = decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: true, candidateScores: failed.candidates.map((item) => ({ time: item.time, score: item.posterior_score })), discriminatorProbe: selectDiscriminatorProbe(retryPacket), @@ -277,6 +282,7 @@ test("mutated years and domains do not keep asking exam-quality copy", () => { test("user stop is an unvalidated range, not a holdout pass", () => { const stopped = decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: true, userStopped: true, candidateScores: [ @@ -293,6 +299,7 @@ test("user stop is an unvalidated range, not a holdout pass", () => { test("tied candidates without a high-information probe offer a range instead of inventing left/right", () => { const next = decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: true, trainingGateOpen: true, candidateScores: [ @@ -378,6 +385,7 @@ test("case A: two then three events keep collecting; four training events may di })), }); return decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: true, trainingGateOpen: state.events.filter((item) => item.usage === "training").length >= 3 && new Set(state.events.filter((item) => item.usage === "training").map((item) => item.domain)).size >= 2, diff --git a/frontend/tests/rectification-holdout-renderable.test.ts b/frontend/tests/rectification-holdout-renderable.test.ts index be82999d..5ba0f98e 100644 --- a/frontend/tests/rectification-holdout-renderable.test.ts +++ b/frontend/tests/rectification-holdout-renderable.test.ts @@ -97,7 +97,13 @@ function producedDossier( })), representativeTime: state.representative_time, evidenceLedgerFingerprint: evidenceLedgerFingerprint(evidence as never), - decisionReceipt: { inference_state: state }, + decisionReceipt: { + acceptance_allowed: true, + selection_allowed: true, + propose_allowed: true, + confirmation_allowed: false, + inference_state: state, + }, }; return { state, @@ -121,7 +127,7 @@ function holdoutFollowup(dossier: ReturnType["dossier"]) }); } -test("sticky holdout with unknown precision does not ask validation and can adopt", () => { +test("sticky holdout with unknown precision stays review-only", () => { const { state, dossier: dated } = producedDossier(COVERED_EVIDENCE); const holdout = state.events.find((item) => item.usage === "holdout"); assert.ok(holdout); @@ -141,11 +147,14 @@ test("sticky holdout with unknown precision does not ask validation and can adop const decision = decideFromDossier(dossier); assert.notEqual(decision.nextAction, "ask_holdout_validation"); - assert.equal(decision.nextAction, "ready_to_adopt"); - assert.equal(decision.sessionOutcome, "adopt_representative"); + // 原值是 ready_to_adopt;unknown precision 让 holdout 不可用,不能进入采用态。 + assert.equal(decision.nextAction, "offer_provisional_range"); + // 原值是 adopt_representative;未通过独立核对只能交付临时区间。 + assert.equal(decision.sessionOutcome, "provisional_range"); assert.notEqual(decision.sessionOutcome, "validated_range"); assert.equal(decision.validated, false); - assert.equal(decision.canAdopt, true); + // 原值是 true;holdout unavailable 必须 fail-closed。 + assert.equal(decision.canAdopt, false); assert.equal(decision.canConfirmExactMinute, false); }); @@ -169,7 +178,10 @@ test("passed holdout is a validated range, not a unique minute", () => { ...dossier, latestResult: { ...dossier.latestResult, - decisionReceipt: { inference_state: passed }, + decisionReceipt: { + ...dossier.latestResult.decisionReceipt, + inference_state: passed, + }, }, }); assert.equal(decided.sessionOutcome, "validated_range"); @@ -187,7 +199,10 @@ test("failed holdout keeps existing non-validated close or discriminate path", ( ...dossier, latestResult: { ...dossier.latestResult, - decisionReceipt: { inference_state: failed }, + decisionReceipt: { + ...dossier.latestResult.decisionReceipt, + inference_state: failed, + }, }, }); assert.equal(decided.holdoutValidation, "failed"); diff --git a/frontend/tests/rectification-ingest-p0.test.ts b/frontend/tests/rectification-ingest-p0.test.ts index 8af20343..04a7f7c3 100644 --- a/frontend/tests/rectification-ingest-p0.test.ts +++ b/frontend/tests/rectification-ingest-p0.test.ts @@ -21,6 +21,7 @@ import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9 import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { createRectificationV9Tools, latestResultToolProjection } from "../src/mastra/rectification-v9-tools.ts"; import { + OPEN_ENGINE_CAPABILITY_CEILING, CASE_ID, CANDIDATE_ID, EVIDENCE_ID, @@ -128,6 +129,7 @@ test("a 25-minute tied plateau projects width and forbids unique-minute confirma selectionKind: null, algorithmVersion: "rectification-v5", }, decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, trainingGateOpen: false, candidateScores: candidates.map((item) => ({ time: item.time, score: item.relativeSupport })), diff --git a/frontend/tests/rectification-occupation-coverage-exit.test.ts b/frontend/tests/rectification-occupation-coverage-exit.test.ts index 74222a15..cb3f7043 100644 --- a/frontend/tests/rectification-occupation-coverage-exit.test.ts +++ b/frontend/tests/rectification-occupation-coverage-exit.test.ts @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts"; + import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts"; import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; @@ -309,12 +311,11 @@ test("training gate closed still withholds yearless varga cards", () => { test("training complete with no renderable distinguish card allows offering a range without adopt", () => { const blocked = decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, trainingGateOpen: true, candidateScores: NARROW_LEAD, discriminatorProbe: null, - engineAcceptAllowed: true, - engineProposeAllowed: true, }); assert.equal(blocked.canOfferRange, true); assert.equal(blocked.canAdopt, false); @@ -322,12 +323,11 @@ test("training complete with no renderable distinguish card allows offering a ra assert.equal(blocked.nextAction, "offer_provisional_range"); assert.equal(blocked.sessionOutcome, "provisional_range"); const stillClosed = decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, trainingGateOpen: false, candidateScores: NARROW_LEAD, discriminatorProbe: null, - engineAcceptAllowed: true, - engineProposeAllowed: true, }); assert.equal(stillClosed.canOfferRange, false); assert.equal(stillClosed.canAdopt, false); @@ -352,8 +352,10 @@ test("decideFromDossier offers a range when training is complete and the ask lay evidenceLedgerFingerprint: evidenceLedgerFingerprint(CASE_EVIDENCE as never), decisionReceipt: { accept_allowed: true, + acceptance_allowed: true, propose_allowed: true, selection_allowed: true, + confirmation_allowed: false, }, }, case: { acceptedTime: null }, @@ -440,6 +442,7 @@ test("a mismatched receipt house_table is not emitted with another representativ test("tool projection does not pair a 05:00 house table with a 04:47 representative time", () => { const decision = decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: true, trainingGateOpen: true, candidateScores: TIED, diff --git a/frontend/tests/rectification-range-offer-deadend.test.ts b/frontend/tests/rectification-range-offer-deadend.test.ts index 9724577e..03742ca9 100644 --- a/frontend/tests/rectification-range-offer-deadend.test.ts +++ b/frontend/tests/rectification-range-offer-deadend.test.ts @@ -32,6 +32,7 @@ import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { + OPEN_ENGINE_CAPABILITY_CEILING, CASE_ID, FOCUS_ID, TURN_ID, @@ -334,8 +335,10 @@ function liveDossier( representativeTime: "04:51", decisionReceipt: { accept_allowed: true, + acceptance_allowed: true, propose_allowed: true, selection_allowed: true, + confirmation_allowed: false, inference_state: state, ...(extra.eventProbes ? { @@ -401,8 +404,10 @@ function rpcDossier(decision: DecisionDossier) { candidates: snapshotCandidates(), decisionReceipt: { accept_allowed: true, + acceptance_allowed: true, propose_allowed: true, selection_allowed: true, + confirmation_allowed: false, ...(decision.latestResult?.decisionReceipt ?? {}), }, }), @@ -583,23 +588,24 @@ test("offerRangeWithoutAdopt persists a spoken collect and narrates the numeric assert.equal(Boolean(idle.hostNarration) && Boolean(question), true); }); -test("userStopped still completes as provisional_range_user_stopped with canAdopt", () => { +test("userStopped completes with a review-only provisional range", () => { const stopped = decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, trainingGateOpen: true, userStopped: true, - engineAcceptAllowed: true, - engineProposeAllowed: true, candidateScores: ACTIVE.map((time) => ({ time, score: SCORES[time] ?? 0 })), }); assert.equal(stopped.sessionOutcome, "provisional_range_user_stopped"); - assert.equal(stopped.canAdopt, true); + // 原值是 true;用户停止只结束追问,不能绕过 coverage 和 holdout 门槛。 + assert.equal(stopped.canAdopt, false); assert.equal(stopped.canOfferRange, true); assert.equal(isNonConvergingRangeOffer(stopped), false); const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION], { status: "paused" }); const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" }); assert.equal(fromDossier.sessionOutcome, "provisional_range_user_stopped"); - assert.equal(fromDossier.canAdopt, true); + // 原值是 true;该 dossier 只有两个同域日期事件,停止不等于证据达标。 + assert.equal(fromDossier.canAdopt, false); }); test("idle persist still decides from the dossier once and does not invent collect_evidence", () => { diff --git a/frontend/tests/rectification-v10-tool-contract.test.ts b/frontend/tests/rectification-v10-tool-contract.test.ts index ac5b1c13..dedf4eb6 100644 --- a/frontend/tests/rectification-v10-tool-contract.test.ts +++ b/frontend/tests/rectification-v10-tool-contract.test.ts @@ -5,6 +5,7 @@ import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/ import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { createRectificationV9AgentTools, createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts"; import { + OPEN_ENGINE_CAPABILITY_CEILING, CASE_ID, EVIDENCE_ID, FOCUS_ID, @@ -212,6 +213,7 @@ test("agent tools omit offer-candidates when proposeAllowed is false", () => { turnId: TURN_ID, accounting: fakeAccounting({}).client as never, }, decideRectification({ + engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, methodCoverageAll: false, trainingGateOpen: false, candidateScores: [], diff --git a/frontend/tests/rectification-v9-engine-contract.test.ts b/frontend/tests/rectification-v9-engine-contract.test.ts index 552ac248..b9b019a7 100644 --- a/frontend/tests/rectification-v9-engine-contract.test.ts +++ b/frontend/tests/rectification-v9-engine-contract.test.ts @@ -25,6 +25,7 @@ const DECISION_RECEIPT = { display_allowed: true, selection_allowed: true, acceptance_allowed: true, + propose_allowed: true, confirmation_allowed: false, accept_allowed: true, confirm_allowed: false, diff --git a/frontend/tests/rectification-v9-test-support.ts b/frontend/tests/rectification-v9-test-support.ts index f86ea7c4..fecd0dc4 100644 --- a/frontend/tests/rectification-v9-test-support.ts +++ b/frontend/tests/rectification-v9-test-support.ts @@ -53,6 +53,13 @@ export const SOURCE_TURN_ID = "77777777-7777-4777-8777-777777777777"; export const RECTIFICATION_SKILL_SHA256 = "5acb3103e80993ea611b93d2c1746e70b74aff8f8636bcffc80a837b954b470d"; export const RECTIFICATION_SKILL_SOURCE_COMMIT = "0fd111d16b45796086a6c1d0945dbd3de6755d8a"; +export const OPEN_ENGINE_CAPABILITY_CEILING = { + acceptanceAllowed: true, + selectionAllowed: true, + proposeAllowed: true, + confirmationAllowed: true, +} as const; + export const CANDIDATE_RANGE = { start_time: "04:50", end_time: "05:10" }; export function dossierFixture(overrides: { @@ -251,6 +258,7 @@ export function candidateSnapshotFixture(overrides: { policy_version: "rectification-candidate-policy-v2", selection_allowed: overrides.selectionAllowed ?? true, acceptance_allowed: overrides.selectionAllowed ?? true, + propose_allowed: overrides.selectionAllowed ?? true, confirmation_allowed: overrides.confirmationAllowed ?? false, representative_candidate_id: overrides.representativeTime ? CANDIDATE_ID : null, overall_confidence: "medium",