Files
Jyotisha/frontend/tests/rectification-fewer-probes-card-20260926.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

318 lines
15 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 { readFileSync } from "node:fs";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { INFERENCE_ALGORITHM_VERSION, type InferenceCandidate, type InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import { buildRangeDelivery } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import {
RANGE_DELIVERY_PERCENT_MIN_GAP,
RECTIFICATION_USER_COPY,
listUserVisibleCopy,
rangeDeliveryMostLikely,
rangeDeliveryShowsPercents,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
GUIDED_WINDOW_CASE_LIMIT,
cardHoldingLinesExhausted,
guidedCollectExhausted,
guidedWindowPool,
type CollectionEvidence,
type GuidedCollectWindow,
} from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
import { buildMethodFollowupPlan, targetedCollectFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { RectificationRangeDelivery } from "../src/components/rectification-range-delivery.tsx";
import type { RectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts";
// Fictional data only. Three columns inside 05:00–05:15.
function cand(time: string, probability: number): InferenceCandidate {
return {
id: time,
time,
cluster_range: [time, time],
prior_score: probability * 100,
posterior_score: probability * 100,
probability,
status: "active",
rank: 1,
strong_conflict_count: 0,
};
}
function inference(probabilities: readonly [number, number, number]): InferenceState {
return {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: "05:00-05:15:05:02,05:07,05:13",
revision: 1,
phase: "discrimination",
result_status: "credible_range",
range_start: "05:00",
range_end: "05:15",
candidates: [
cand("05:07", probabilities[0]),
cand("05:02", probabilities[1]),
cand("05:13", probabilities[2]),
],
events: [
{ id: "e1", domain: "career", year: 2018, precision: "month", usage: "training" },
{ id: "e2", domain: "education", year: 2012, precision: "month", usage: "training" },
],
probes: [],
answered_probes: [],
rounds: [],
entropy: 1,
representative_time: "05:07",
credible_range: ["05:00", "05:15"],
};
}
const PUBLIC = [
{ candidateId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", time: "05:07" },
{ candidateId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2", time: "05:02" },
{ candidateId: "cccccccc-cccc-4ccc-8ccc-ccccccccccc3", time: "05:13" },
];
function result(probabilities: readonly [number, number, number]): RectificationCandidateResult {
const delivery = buildRangeDelivery({
inference: inference(probabilities),
publicCandidates: PUBLIC,
credibleRange: ["05:00", "05:15"],
representativeTime: "05:07",
});
return {
resultId: "result-1",
candidates: delivery.columns.map((column, index) => ({
candidateId: column.candidate_id,
rank: index + 1,
time: column.time,
relativeSupport: 10,
tiedMinuteCount: 1,
})),
overallConfidence: "medium",
selectionAllowed: true,
canAdopt: true,
confirmationAllowed: false,
decisionReceipt: null,
representativeTime: delivery.representative_time,
selectedTime: null,
selectionKind: null,
houseTable: null,
houseTablesByTime: {},
natalRecast: null,
techniqueAudit: [],
windowTransitions: [],
eventDashaLedger: [],
dashaAgreement: null,
lagnaContrast: null,
nakshatraBoundary: null,
precisionStage: null,
oosBlindPrompts: [],
confirmationGate: { confirmation_allowed: false } as RectificationCandidateResult["confirmationGate"],
validated: false,
completionStatus: null,
sessionOutcome: "adopt_representative",
credibleRange: delivery.range,
rangeDelivery: delivery,
verificationReportMarkdown: null,
};
}
function render(probabilities: readonly [number, number, number]): string {
return renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result: result(probabilities),
acceptingCandidateId: null,
readonly: false,
onAccept: () => undefined,
}));
}
test("D4 threshold is 5 points between the first and second column", () => {
assert.equal(RANGE_DELIVERY_PERCENT_MIN_GAP, 5);
assert.equal(rangeDeliveryShowsPercents([{ probability_percent: 40 }, { probability_percent: 35 }]), true);
assert.equal(rangeDeliveryShowsPercents([{ probability_percent: 30 }, { probability_percent: 26 }]), false);
assert.equal(rangeDeliveryShowsPercents([
{ probability_percent: 26 },
{ probability_percent: 26 },
{ probability_percent: 20 },
]), false);
// Order-independent: the gap is first − second after ranking.
assert.equal(rangeDeliveryShowsPercents([{ probability_percent: 30 }, { probability_percent: 45 }]), true);
// One column has no second place to be clearly ahead of.
assert.equal(rangeDeliveryShowsPercents([{ probability_percent: 100 }]), false);
});
test("D3 range is the title and the representative minute is the subtitle", () => {
const html = render([0.45, 0.3, 0.25]);
assert.match(html, /<strong>目前范围 05:00–05:15(对照了 2 件经历)<\/strong>/);
assert.match(html, /rectification-range-delivery__most-likely">最可能 05:07</);
assert.ok(html.indexOf("最可能 05:07") < html.indexOf("rectification-range-delivery__columns"));
assert.equal(rangeDeliveryMostLikely("05:07"), "最可能 05:07");
});
test("D4 gap ≥ 5 shows every column's percentage and no indistinct line", () => {
const html = render([0.45, 0.3, 0.25]);
assert.match(html, /相对可能性 45%/);
assert.match(html, /相对可能性 30%/);
assert.match(html, /相对可能性 25%/);
assert.doesNotMatch(html, new RegExp(RECTIFICATION_USER_COPY.rangeDeliveryIndistinct));
});
test("D4 gap < 5 shows no numbers and one indistinct sentence; columns and order unchanged", () => {
const html = render([0.35, 0.33, 0.32]);
assert.doesNotMatch(html, /相对可能性/);
assert.doesNotMatch(html, /\d+%/);
assert.equal(html.split(RECTIFICATION_USER_COPY.rangeDeliveryIndistinct).length - 1, 1);
// 原值: 「这几个时刻目前区分不开,补一件带年月的经历能帮助分开。」
// 新值: 「这几个时刻按现在的方法区分不开。」
// 原因: 门开后补经历不收窄范围,不再邀请(BUG-1084,2026-09-29 D2,推翻 09-26 D4 的邀请句)
assert.equal(
RECTIFICATION_USER_COPY.rangeDeliveryIndistinct,
"这几个时刻按现在的方法区分不开。",
);
assert.equal([...html.matchAll(/<button\b/g)].length, 3);
const order = ["05:07", "05:02", "05:13"].map((time) => html.indexOf(`__time">${time}`));
assert.deepEqual([...order].sort((left, right) => left - right), order);
assert.ok(order.every((index) => index > 0));
// Acceptance 2026-09-26: 「最可能」 beside 「区分不开」 contradicts itself, so
// the subtitle is hidden when the columns are indistinct.
assert.doesNotMatch(html, /最可能/);
});
test("D3/D4 copy is registered as user-visible copy", () => {
const visible = listUserVisibleCopy();
assert.ok(visible.includes(RECTIFICATION_USER_COPY.rangeDeliveryIndistinct));
assert.ok(visible.includes(rangeDeliveryMostLikely("05:07")));
const voice = readFileSync(new URL("../docs/VOICE.md", import.meta.url), "utf8");
assert.ok(voice.includes(RECTIFICATION_USER_COPY.rangeDeliveryIndistinct));
assert.ok(voice.includes("最可能 HH:MM"));
});
const FRESH_WINDOWS: readonly GuidedCollectWindow[] = [
{ year: 2019, month_lo: 4, month_hi: 6, domain: "any", split: { left: 2, right: 1 } },
{ year: 2015, month_lo: 9, month_hi: 9, domain: "any", split: { left: 1, right: 2 } },
];
function askedWindow(year: number, lo: number, hi: number, status = "declined") {
return {
questionId: `collect:guided:window:${year}:${lo}:${hi}:any`,
target_domain: "any",
status,
intent: "collect_method_evidence",
target_kind: "guided:window:any",
};
}
test("D1 at most two guided windows per Case even when a new receipt brings fresh ones", () => {
assert.equal(GUIDED_WINDOW_CASE_LIMIT, 2);
const oneAsked = [askedWindow(2021, 1, 3)];
assert.equal(guidedWindowPool(FRESH_WINDOWS, [], oneAsked).length, 2);
const twoAsked = [askedWindow(2021, 1, 3), askedWindow(2017, 5, 5)];
assert.equal(guidedWindowPool(FRESH_WINDOWS, [], twoAsked).length, 0);
// The same window re-asked under `:next` is still one window.
const sameTwice = [askedWindow(2021, 1, 3), { ...askedWindow(2021, 1, 3), questionId: "collect:guided:window:2021:1:3:any:next" }];
assert.equal(guidedWindowPool(FRESH_WINDOWS, [], sameTwice).length, 2);
// An active (being asked) window does not count as asked yet.
assert.equal(guidedWindowPool(FRESH_WINDOWS, [], [askedWindow(2021, 1, 3), askedWindow(2017, 5, 5, "active")]).length, 2);
// With the cap reached the next question falls through to the targeted lines.
const followup = targetedCollectFollowup(["d9"], [], twoAsked, ["05:00", "05:15"], 3, ["05:00", "05:15"], FRESH_WINDOWS);
assert.ok(followup?.collection_key?.startsWith("collect:targeted:"), followup?.collection_key);
});
const ALL_SEVEN: CollectionEvidence[] = [
{ status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2012-09-01", occurredTo: "2012-09-01" },
{ status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-07-01", occurredTo: "2018-07-01" },
{ status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2016-08-01", occurredTo: "2016-08-01" },
{ status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2021-08-01", occurredTo: "2021-08-01" },
{ status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2019-01-01", occurredTo: "2019-01-01" },
{ status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2020-03-01", occurredTo: "2020-03-01" },
{ status: "confirmed", domain: "health_pressure", datePrecision: "month", occurredFrom: "2022-04-01", occurredTo: "2022-04-01" },
];
const LAYERS = ["d9", "d10", "d4", "d5", "d7", "d2", "d30"];
test("D2 unasked guided windows no longer hold the card once the targeted seven are asked", () => {
// The whole guided pool still reports a window left…
// 原值: guidedCollectExhausted(...) === false(引导窗口仍在池里)
// 新值: true(门开后引导窗口池为空)
// 原因: BUG-1084,2026-09-29 D1;门未开时的池见 rectification-futile-collect-stop-20260929.test.ts
assert.equal(guidedCollectExhausted(LAYERS, ALL_SEVEN, [], FRESH_WINDOWS, ["05:00", "05:15"], 3), true);
// …but the lines that may hold the card are asked out.
assert.equal(cardHoldingLinesExhausted(LAYERS, ALL_SEVEN, [], ["05:00", "05:15"], 3), true);
});
test("D2 the skip re-ask and a pending year answer still hold the card", () => {
const skipped = [{
questionId: "collect:targeted:family",
target_domain: "family",
status: "skipped",
intent: "collect_method_evidence",
target_kind: "targeted:family",
}];
const withoutFamily = ALL_SEVEN.filter((row) => row.domain !== "family");
// 原值(三处): 重问挡卡 false / 已答「有」的窗口年月阶段挡卡 false / 定向线未问完挡卡 false
// 新值: 门开后重问与定向线不再挡卡 → true;年月阶段仍挡卡(进行中的一问问完)→ false 不变
// 原因: BUG-1084,2026-09-29 D1;门未开时重问仍挡卡,见本用例最后一条
assert.equal(cardHoldingLinesExhausted(LAYERS, withoutFamily, skipped, ["05:00", "05:15"], 3), true);
const yesOnWindow = [askedWindow(2019, 4, 6, "resolved")];
assert.equal(cardHoldingLinesExhausted(LAYERS, ALL_SEVEN, yesOnWindow, ["05:00", "05:15"], 3), false);
// Targeted lines still open: keep asking.
// R1(BUG-1193):补第 4 件 / 第 3 域,保持原场景(门开、定向线未问完)。slice(0, 3) → slice(0, 4)。
assert.equal(cardHoldingLinesExhausted(LAYERS, ALL_SEVEN.slice(0, 4), [], ["05:00", "05:15"], 3), true);
// Before the training gate the re-ask still holds the card.
assert.equal(cardHoldingLinesExhausted(LAYERS, [], skipped, ["05:00", "05:15"], 3), false);
});
test("D2 gate unmet with every card-holding line asked delivers; D5 range rules unchanged", () => {
const decision = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: true,
trainingGateOpen: true,
candidateScores: [
{ time: "05:02", score: 13 },
{ time: "05:07", score: 13.4 },
{ time: "05:13", score: 13.1 },
],
holdoutValidation: "unavailable",
datedEventCount: 7,
datedDomainCount: 7,
discriminatorProbe: null,
targetedCollectExhausted: true,
refreshExhausted: true,
inferenceCredibleRange: ["05:00", "05:15"],
openingCandidateRange: ["04:45", "05:15"],
precisionGateMet: false,
guidedCollectExhausted: cardHoldingLinesExhausted(LAYERS, ALL_SEVEN, [], ["05:00", "05:15"], 3),
});
assert.equal(decision.canOfferRange, true);
assert.deepEqual(decision.credibleRange, ["05:00", "05:15"]);
assert.equal(decision.precisionGateMet, false);
assert.equal(decision.canConfirmExactMinute, false);
});
test("D2 a delivered card does not carry an unasked guided window as the next question", () => {
const base = {
evidence: ALL_SEVEN,
declinedTopics: [],
candidatesSeparated: false,
remainingLayers: LAYERS,
remainingSplitTimes: ["05:00", "05:15"] as const,
remainingCandidateCount: 3,
remainingCredibleRange: ["05:00", "05:15"] as const,
guidedWindows: FRESH_WINDOWS,
};
for (const sessionOutcome of ["completed_with_range", "provisional_range", "adopt_representative"] as const) {
const plan = buildMethodFollowupPlan({ ...base, sessionOutcome });
const key = plan.next_followup?.collection_key ?? "";
assert.equal(key.startsWith("collect:guided:window:"), false, `${sessionOutcome}: ${key}`);
}
// While still collecting, the same windows are asked (at most two per Case).
// 原值: collect_evidence 时下一问是引导窗口题 collect:guided:window:*
// 新值: 门开后采集态也不问引导窗口题
// 原因: BUG-1084,2026-09-29 D1(产品决定删掉门开后的引导补经历题)
const collecting = buildMethodFollowupPlan({ ...base, sessionOutcome: "collect_evidence" });
assert.doesNotMatch(collecting.next_followup?.collection_key ?? "", /^collect:guided:window:/);
});