diff --git a/frontend/src/lib/birth-time-dynamic-stop-policy.ts b/frontend/src/lib/birth-time-dynamic-stop-policy.ts index 57b6c62b..d2936b6a 100644 --- a/frontend/src/lib/birth-time-dynamic-stop-policy.ts +++ b/frontend/src/lib/birth-time-dynamic-stop-policy.ts @@ -11,6 +11,11 @@ export type DynamicStopInput = { readonly forcedReason: "user_finished" | "generation_unavailable" | null; }; +// Keep the existing dynamic-stop safety cap as a shared server-owned value. +// Rectification's authoritative decision consumes the same cap; it does not +// define a second threshold. +export const EFFECTIVE_ANSWER_SAFETY_CAP = 10; + export type DynamicStopDecision = | { readonly kind: "finish"; @@ -42,7 +47,9 @@ export function decideDynamicStop(input: DynamicStopInput): DynamicStopDecision if (input.result?.confidence === "high" && input.result.canApply) { return { kind: "finish", reason: "high_confidence", plateauCount }; } - if (input.effectiveAnswerCount >= 10) return { kind: "finish", reason: "safety_cap", plateauCount }; + if (input.effectiveAnswerCount >= EFFECTIVE_ANSWER_SAFETY_CAP) { + return { kind: "finish", reason: "safety_cap", plateauCount }; + } if (plateauCount >= 2) return { kind: "finish", reason: "plateau", plateauCount }; if (input.usefulOpportunityCount === 0) return { kind: "finish", reason: "no_information_gain", plateauCount }; if (input.repeatedOnly) return { kind: "finish", reason: "repeated_partition", plateauCount }; 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 dbf0ce4c..7a3f5d33 100644 --- a/frontend/src/lib/rectification-agentic/core/decide-next-action.ts +++ b/frontend/src/lib/rectification-agentic/core/decide-next-action.ts @@ -27,6 +27,9 @@ export type DecideNextActionInput = Readonly<{ holdoutValidation?: HoldoutValidationStatus; accepted?: boolean; inferenceCredibleRange?: readonly [string, string] | null; + inferenceRounds?: number; + effectiveAnswerCount?: number; + plateauRounds?: number; }>; export type RectificationNextAction = Readonly<{ @@ -55,6 +58,9 @@ export function decideNextAction(input: DecideNextActionInput): RectificationNex inferenceCredibleRange: input.inferenceCredibleRange, engineAcceptAllowed: input.selectionAllowed, engineProposeAllowed: input.proposeAllowed, + inferenceRounds: input.inferenceRounds, + effectiveAnswerCount: input.effectiveAnswerCount, + plateauRounds: input.plateauRounds, }); return { type: decision.nextAction, separation: decision.separation, probe: decision.probe }; } diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index 753f5cdb..dade8b7a 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -14,7 +14,13 @@ import { } from "./candidate-separation.ts"; import { rangeFromTimes } from "./credible-range.ts"; import type { DroppedProbe } from "../v9/probe-question-contract.ts"; -import type { RectificationPhase, ResultStatus } from "./types.ts"; +import { EFFECTIVE_ANSWER_SAFETY_CAP } from "../../birth-time-dynamic-stop-policy.ts"; +import { RECTIFICATION_POLICY } from "../../rectification-policy.ts"; +import { + DEFAULT_MAX_DISCRIMINATION_ROUNDS, + type RectificationPhase, + type ResultStatus, +} from "./types.ts"; export type RectificationNextActionType = | "ask_fact_collection" @@ -81,6 +87,10 @@ export type DecideRectificationInput = Readonly<{ engineAcceptAllowed?: boolean; engineProposeAllowed?: boolean; datedMethodCollectOpen?: boolean; + /** Server-persisted inference counters; never infer these from chat text. */ + inferenceRounds?: number; + effectiveAnswerCount?: number; + plateauRounds?: number; }>; export function decideRectification(input: DecideRectificationInput): RectificationDecision { @@ -136,7 +146,13 @@ export function decideRectification(input: DecideRectificationInput): Rectificat } if (input.snapshotCurrent === false) { if (probe && !userStopped && input.trainingGateOpen !== false) { - return discriminate(separation, holdout, range, probe); + return discriminateOrExhaust( + input, + separation, + holdout, + range, + probe, + ); } if (!(userStopped && input.candidateScores.length > 0)) { return collect(separation, holdout, range, probe); @@ -144,7 +160,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat } if (!separation.sufficient) { if (probe && !userStopped) { - return discriminate(separation, holdout, range, probe); + return discriminateOrExhaust(input, separation, holdout, range, probe); } return completeWithRange(separation, holdout, range, userStopped ? "user_stopped" : "offer"); } @@ -153,7 +169,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat } if (holdout === "failed") { if (probe && !userStopped) { - return discriminate(separation, holdout, range, probe); + return discriminateOrExhaust(input, separation, holdout, range, probe); } return completeWithRange(separation, holdout, range, "exhausted"); } @@ -183,6 +199,25 @@ export function decideRectification(input: DecideRectificationInput): Rectificat }); } +function budgetExhausted(input: DecideRectificationInput): boolean { + return (input.inferenceRounds ?? 0) >= DEFAULT_MAX_DISCRIMINATION_ROUNDS + || (input.effectiveAnswerCount ?? 0) >= EFFECTIVE_ANSWER_SAFETY_CAP + || (input.plateauRounds ?? 0) >= RECTIFICATION_POLICY.maxPlateauRounds; +} + +function discriminateOrExhaust( + input: DecideRectificationInput, + separation: CandidateSeparation, + holdout: HoldoutValidationStatus, + range: readonly [string, string] | null, + probe: CandidateDiscriminatorProbe, +): RectificationDecision { + if (budgetExhausted(input)) { + return completeWithRange(separation, holdout, range, "exhausted"); + } + return discriminate(separation, holdout, range, probe); +} + function collect( separation: CandidateSeparation, holdout: HoldoutValidationStatus, 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 e8e3962f..b2285df4 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -355,6 +355,22 @@ export type DecideFromDossierOptions = Readonly<{ birthDate?: string | null; }>; +function decisionBudgetFromInference(inference: InferenceState | null | undefined) { + const rounds = inference?.rounds ?? []; + let plateauRounds = 0; + for (let index = rounds.length - 1; index >= 0; index -= 1) { + if (rounds[index]?.kind !== "low_information") break; + plateauRounds += 1; + } + return { + // The existing convergence evaluator budgets informative rounds. Keep the + // authoritative decision aligned with that persisted interpretation. + inferenceRounds: rounds.filter((item) => item.kind === "informative").length, + effectiveAnswerCount: inference?.answered_probes.length ?? 0, + plateauRounds, + }; +} + function completedProbeForSemanticKey( semanticKey: string, packet: CandidateContrastPacket | null | undefined, @@ -440,6 +456,7 @@ export function decideFromDossier( sessionOutcome: "collect_evidence", }); const latest = dossier.latestResult; + const decisionBudget = decisionBudgetFromInference(inference); const snapshotCurrent = scoreableSnapshotCurrentFromDossier(dossier, options, inference); const confirmationGate = buildConfirmationGate({ engineConfirmationAllowed: latest?.confirmationAllowed === true, @@ -480,6 +497,7 @@ export function decideFromDossier( || latest?.decisionReceipt?.accept_allowed === true, engineProposeAllowed: latest?.decisionReceipt?.propose_allowed === true, datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods), + ...decisionBudget, }), droppedProbes: gated.dropped, }; @@ -503,6 +521,7 @@ export function decideAfterInferenceChange(input: { trainingGateOpen: trainingScoreableGate(input.dossier.evidence).open, candidateScores: [], userStopped: input.userStopped, + ...decisionBudgetFromInference(null), }); } const training = input.state.events.filter((item) => item.usage === "training"); @@ -543,6 +562,7 @@ export function decideAfterInferenceChange(input: { || input.dossier.latestResult?.decisionReceipt?.accept_allowed === true, engineProposeAllowed: input.dossier.latestResult?.decisionReceipt?.propose_allowed === true, datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods), + ...decisionBudgetFromInference(input.state), }), droppedProbes: gated.dropped, }; diff --git a/frontend/tests/rectification-convergence-budget.test.ts b/frontend/tests/rectification-convergence-budget.test.ts new file mode 100644 index 00000000..772b37f8 --- /dev/null +++ b/frontend/tests/rectification-convergence-budget.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { EFFECTIVE_ANSWER_SAFETY_CAP } from "../src/lib/birth-time-dynamic-stop-policy.ts"; +import { DEFAULT_MAX_DISCRIMINATION_ROUNDS } from "../src/lib/rectification-agentic/core/types.ts"; +import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; +import { RECTIFICATION_POLICY } from "../src/lib/rectification-policy.ts"; +import type { CandidateDiscriminatorProbe } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; + +const PROBE: CandidateDiscriminatorProbe = { + probeId: "probe-1", + candidateSetVersion: "set-1", + question: "这件事更接近哪一种情况?", + expectedOutcomes: [ + { + outcomeId: "yes", + supportsCandidateIds: ["05:00"], + conflictsCandidateIds: ["05:06", "05:07"], + }, + { + outcomeId: "no", + supportsCandidateIds: ["05:06", "05:07"], + conflictsCandidateIds: ["05:00"], + }, + ], + candidateSplitHash: "05:00|05:06|05:07", + informationGain: 0.5, + sourceFeatures: [{ technique: "test", calculationResultId: null }], + domain: "career", + year: 2020, + semanticKey: "career.2020.test", +}; + +const BASE_INPUT = { + methodCoverageAll: true, + trainingGateOpen: true, + candidateScores: [ + { time: "05:00", score: 34 }, + { time: "05:06", score: 33 }, + { time: "05:07", score: 33 }, + ], + discriminatorProbe: PROBE, + holdoutValidation: "unavailable" as const, +}; + +function decideWithBudget(budget: { + inferenceRounds?: number; + effectiveAnswerCount?: number; + plateauRounds?: number; +}) { + return decideRectification({ ...BASE_INPUT, ...budget }); +} + +test("every persisted discrimination budget terminates before asking another probe", () => { + for (const budget of [ + { inferenceRounds: DEFAULT_MAX_DISCRIMINATION_ROUNDS }, + { effectiveAnswerCount: EFFECTIVE_ANSWER_SAFETY_CAP }, + { plateauRounds: RECTIFICATION_POLICY.maxPlateauRounds }, + ]) { + const decision = decideWithBudget(budget); + assert.equal(decision.nextAction, "complete_with_range"); + assert.equal(decision.sessionOutcome, "completed_with_range"); + assert.notEqual(decision.nextAction, "ask_candidate_discriminator"); + } +}); + +test("repeated declined or unsure answers reach the existing plateau terminal", () => { + const decision = decideWithBudget({ + inferenceRounds: 0, + effectiveAnswerCount: RECTIFICATION_POLICY.maxPlateauRounds, + plateauRounds: RECTIFICATION_POLICY.maxPlateauRounds, + }); + assert.equal(decision.nextAction, "complete_with_range"); + assert.equal(decision.sessionOutcome, "completed_with_range"); +}); + +test("exhausted discrimination still delivers the credible candidate range", () => { + const decision = decideWithBudget({ + inferenceRounds: DEFAULT_MAX_DISCRIMINATION_ROUNDS, + effectiveAnswerCount: 0, + plateauRounds: 0, + }); + assert.equal(decision.resultStatus, "completed_with_range"); + assert.equal(decision.sessionOutcome, "completed_with_range"); + assert.equal(decision.canOfferRange, true); + assert.equal(decision.canAdopt, true); + assert.deepEqual(decision.credibleRange, ["05:00", "05:07"]); +}); + +test("additional score evidence never widens the credible range", () => { + const before = decideRectification({ + ...BASE_INPUT, + candidateScores: [ + { time: "05:00", score: 34 }, + { time: "05:06", score: 33 }, + { time: "05:07", score: 33 }, + ], + inferenceRounds: DEFAULT_MAX_DISCRIMINATION_ROUNDS, + }); + const after = decideRectification({ + ...BASE_INPUT, + candidateScores: [ + { time: "05:00", score: 42 }, + { time: "05:06", score: 33 }, + { time: "05:07", score: 33 }, + ], + inferenceRounds: DEFAULT_MAX_DISCRIMINATION_ROUNDS, + }); + + const width = (range: readonly [string, string] | null) => { + assert.ok(range); + const toMinutes = (time: string) => Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5)); + return toMinutes(range[1]) - toMinutes(range[0]); + }; + + assert.ok(width(after.credibleRange) <= width(before.credibleRange)); +});