Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
303 lines
13 KiB
TypeScript
303 lines
13 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);
|
||
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…
|
||
assert.equal(guidedCollectExhausted(LAYERS, ALL_SEVEN, [], FRESH_WINDOWS, ["05:00", "05:15"], 3), false);
|
||
// …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");
|
||
assert.equal(cardHoldingLinesExhausted(LAYERS, withoutFamily, skipped, ["05:00", "05:15"], 3), false);
|
||
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.
|
||
assert.equal(cardHoldingLinesExhausted(LAYERS, ALL_SEVEN.slice(0, 3), [], ["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).
|
||
const collecting = buildMethodFollowupPlan({ ...base, sessionOutcome: "collect_evidence" });
|
||
assert.match(collecting.next_followup?.collection_key ?? "", /^collect:guided:window:/);
|
||
});
|