Files
Jyotisha/frontend/tests/rectification-tied-first-fix-20260914.test.ts
T
Jesse_ChenandClaude Opus 5.5 758fee954b feat(rectification): range delivery needs 4 dated events across 3 domains (R1, BUG-1193)
Product decision 2026-10-02 (TASK-upstream-sync5 R1): a time range is offered
only with at least 4 dated, primary-scoreable events covering 3 domains,
counted on all of them (training + reserved holdout). Was 3 training events /
2 domains in three TS copies and the Python acceptance gate while the policy
file already said 4/3.

- One definition: references/rectification_policy.v1.json
  (minConfirmationEvents / minConfirmationDomains). TS core/types MIN_DATED_*,
  rectification-decision MIN_STANDALONE_*, evidence-model MIN_ACCEPTANCE_*,
  the convergence evaluator and the post-inference trainingGateOpen all read
  it; Python decision_policy MIN_ACCEPTANCE_* alias MIN_CONFIRMATION_*.
- Python receipt counts all scoreable events / domains for event_quality and
  domain_diversity; decision policy identity v3 -> v4 (candidate UUIDs carry
  it). Candidate scores unchanged (77 v5 cases A/B identical), so the
  algorithm stays rectification-v5-matrix-scoring-10.
- Memoization golden v3 written by write_golden; v2 frozen by sha256 with a
  test that its scores equal v3 and only the receipt policy moved.
- Collect gap copy names the exact gap ("再来两件……其中至少一件不是……")
  instead of always "再来一件"; VOICE.md updated. Legacy life-events form copy
  4/3 as well.
- 30 frontend test files, 4 Python tests: fixtures extended to the same
  scenario at 4/3, or assertions changed with 原值/新值/原因 notes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-03 00:08:00 +08:00

221 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import test from "node:test";
import {
decideRectification,
publicCanAdopt,
rangeMatchesOpeningWindow,
sessionOutcomeAllowsDelivery,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import {
RECTIFICATION_DELIVERED_COPY,
RECTIFICATION_QUESTION_RETRY_LIMIT,
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
interviewDeliveredGap,
rectificationQuestionGapState,
} from "../src/lib/rectification-surface-state.ts";
import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts";
import type { CandidateDiscriminatorProbe } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
const DATED_PROBE: CandidateDiscriminatorProbe = {
probeId: "probe-2023-career",
candidateSetVersion: "set-1",
question: "2023 年 5 月前后,你有没有入职、换工作,或者当时手上的职责明显变重?",
expectedOutcomes: [
{
outcomeId: "yes",
supportsCandidateIds: ["05:06"],
conflictsCandidateIds: ["05:08", "05:14"],
},
{
outcomeId: "no",
supportsCandidateIds: ["05:08", "05:14"],
conflictsCandidateIds: ["05:06"],
},
],
candidateSplitHash: "05:06|05:08|05:14",
informationGain: 0.4,
sourceFeatures: [{ technique: "test", calculationResultId: null }],
domain: "career",
year: 2023,
semanticKey: "career.2023.entry",
};
const TIED_THREE = [
{ time: "05:06", score: 13 },
{ time: "05:08", score: 13 },
{ time: "05:14", score: 13 },
{ time: "05:03", score: 12 },
{ time: "04:59", score: 11 },
{ time: "04:46", score: 10 },
{ time: "04:53", score: 10 },
{ time: "05:15", score: 10 },
{ time: "04:50", score: 8 },
] as const;
const OPENING = ["04:45", "05:15"] as const;
function tiedInput(overrides: Partial<Parameters<typeof decideRectification>[0]> = {}) {
return {
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
trainingGateOpen: true,
candidateScores: TIED_THREE.map((item) => ({ time: item.time, score: item.score })),
holdoutValidation: "unavailable" as const,
// R1(BUG-1193):补第 4 件 / 第 3 域,保持原场景。
datedEventCount: 4,
datedDomainCount: 3,
inferenceCredibleRange: OPENING,
openingCandidateRange: OPENING,
...overrides,
};
}
test("tied first place with a dated probe keeps collecting instead of delivering", () => {
const decision = decideRectification(tiedInput({
discriminatorProbe: DATED_PROBE,
targetedCollectExhausted: true,
refreshExhausted: false,
}));
assert.notEqual(decision.nextAction, "complete_with_range");
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false);
assert.equal(publicCanAdopt(decision), false);
assert.equal(decision.canAdopt, false);
assert.equal(decision.nextAction, "ask_candidate_discriminator");
});
test("tied first place with no probe still collects while targeted questions remain", () => {
const decision = decideRectification(tiedInput({
discriminatorProbe: null,
targetedCollectExhausted: false,
refreshExhausted: true,
}));
assert.notEqual(decision.nextAction, "complete_with_range");
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false);
assert.equal(publicCanAdopt(decision), false);
assert.equal(decision.nextAction, "ask_fact_collection");
});
test("tied first place with no probe and targeted collect exhausted delivers a range", () => {
const decision = decideRectification(tiedInput({
discriminatorProbe: null,
targetedCollectExhausted: true,
refreshExhausted: false,
}));
assert.equal(decision.stopReason, "tied_first");
assert.equal(decision.nextAction, "complete_with_range");
assert.equal(decision.canOfferRange, true);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
});
test("a live first-place tie with remaining probes does not announce adopt_representative", () => {
const decision = decideRectification(tiedInput({
discriminatorProbe: DATED_PROBE,
// R1(BUG-1193):补第 4 件 / 第 3 域,保持原场景。
datedEventCount: 4,
targetedCollectExhausted: false,
refreshExhausted: false,
}));
assert.notEqual(decision.sessionOutcome, "adopt_representative");
assert.equal(publicCanAdopt(decision), false);
assert.equal(rangeMatchesOpeningWindow(decision.credibleRange, OPENING), true);
});
test("an unnarrowed delivered range withholds adopt", () => {
const unnarrowed = decideRectification(tiedInput({
discriminatorProbe: null,
targetedCollectExhausted: true,
inferenceCredibleRange: OPENING,
openingCandidateRange: OPENING,
}));
assert.equal(sessionOutcomeAllowsDelivery(unnarrowed.sessionOutcome), true);
assert.equal(unnarrowed.sessionOutcome === "provisional_range"
|| unnarrowed.sessionOutcome === "completed_with_range", true);
assert.equal(unnarrowed.canAdopt, false);
assert.equal(publicCanAdopt(unnarrowed), false);
assert.equal(unnarrowed.canOfferRange, true);
const narrowed = decideRectification(tiedInput({
discriminatorProbe: null,
targetedCollectExhausted: true,
inferenceCredibleRange: ["04:48", "05:07"],
openingCandidateRange: OPENING,
}));
assert.equal(sessionOutcomeAllowsDelivery(narrowed.sessionOutcome), true);
assert.equal(narrowed.canAdopt, true);
assert.equal(narrowed.sessionOutcome, "adopt_representative");
});
test("a delivered dead choice is the delivered gap, not unavailable repair", () => {
const warnings: string[] = [];
const original = console.warn;
console.warn = (value: unknown) => {
warnings.push(String(value));
};
try {
assert.equal(
interviewDeliveredGap({
questionMissing: false,
deadChoice: true,
sessionOutcome: "completed_with_range",
stopReason: "tied_first",
questionId: "distinguish:career.2023.entry",
hasChoiceCard: false,
}),
true,
);
assert.equal(
rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: false,
questionLoadFailed: true,
deadChoice: true,
questionId: "distinguish:career.2023.entry",
hasChoiceCard: false,
busy: false,
readonly: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
sessionOutcome: "completed_with_range",
stopReason: "tied_first",
}),
"delivered",
);
assert.ok(warnings.some((line) => line.includes("rectification_delivered_state_dead_choice")));
} finally {
console.warn = original;
}
assert.equal(RECTIFICATION_DELIVERED_COPY.includes("没有拿到下一个问题"), false);
});
test("a non-delivery dead choice stays on the unavailable repair path", () => {
assert.equal(
interviewDeliveredGap({
questionMissing: false,
deadChoice: true,
sessionOutcome: "collect_evidence",
hasChoiceCard: false,
}),
false,
);
assert.equal(
rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: false,
questionLoadFailed: true,
deadChoice: true,
questionPersisted: false,
collectWaiting: false,
busy: false,
readonly: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
sessionOutcome: "collect_evidence",
}),
"unavailable",
);
assert.equal(RECTIFICATION_QUESTION_UNAVAILABLE_COPY, "没有拿到下一个问题。");
});