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
318 lines
15 KiB
TypeScript
318 lines
15 KiB
TypeScript
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:/);
|
||
});
|