fix(rectification): keep candidate state coherent
This commit is contained in:
@@ -20,6 +20,14 @@ const decisionContractV2Migration = readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const profileFreshnessMigration = readFileSync(
|
||||
new URL(
|
||||
"../supabase/migrations/20260827010000_rectification_candidate_profile_freshness.sql",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("candidate acceptance migration adds accepted status and durable result ownership", () => {
|
||||
assert.match(migration, /birth_time_status in \([\s\S]*'reported'[\s\S]*'assessing'[\s\S]*'rectifying'[\s\S]*'candidate'[\s\S]*'accepted'[\s\S]*'confirmed'[\s\S]*\)/);
|
||||
assert.match(migration, /create table public\.agentic_rectification_results/);
|
||||
@@ -165,3 +173,119 @@ test("decision contract V2 confirmation remains a separate exact and consent-gro
|
||||
assert.match(confirmFunction, /birth_time_status = 'confirmed'/);
|
||||
assert.match(confirmFunction, /selection_kind = 'engine_confirmed'/);
|
||||
});
|
||||
|
||||
test("candidate acceptance shares one calculation-input freshness guard across first accept and reselection", () => {
|
||||
const acceptFunction = profileFreshnessMigration.slice(
|
||||
profileFreshnessMigration.indexOf(
|
||||
"create or replace function public.accept_agentic_rectification_candidate_for_case_v2(",
|
||||
),
|
||||
profileFreshnessMigration.indexOf("commit;"),
|
||||
);
|
||||
const requestReplay = acceptFunction.indexOf("if found then");
|
||||
const freshnessGuard = acceptFunction.indexOf("v_snapshot := v_case.baseline_birth_snapshot;");
|
||||
const selectionBranch = acceptFunction.indexOf("if v_result.selected_candidate_id is not null then");
|
||||
const freshnessChecks = acceptFunction.slice(freshnessGuard, selectionBranch);
|
||||
|
||||
assert.ok(requestReplay >= 0 && requestReplay < freshnessGuard, "exact request replay must remain idempotent");
|
||||
assert.ok(freshnessGuard >= 0 && freshnessGuard < selectionBranch, "freshness must guard first accept and reselection");
|
||||
assert.doesNotMatch(
|
||||
freshnessChecks,
|
||||
/v_profile\.active_birth_time is distinct from \(v_snapshot ->> 'active_birth_time'\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
freshnessChecks,
|
||||
/v_profile\.birth_place_label is distinct from v_snapshot ->> 'birth_place_label'/,
|
||||
);
|
||||
for (const requiredFreshnessCheck of [
|
||||
/v_profile\.birth_date is distinct from \(v_snapshot ->> 'birth_date'\)::date/,
|
||||
/v_profile\.reported_birth_time is distinct from \(v_snapshot ->> 'reported_birth_time'\)::time without time zone/,
|
||||
/v_profile\.birth_time_source is distinct from v_snapshot ->> 'birth_time_source'/,
|
||||
/v_profile\.birth_time_period is distinct from v_snapshot ->> 'birth_time_period'/,
|
||||
/v_profile\.declared_window_start is distinct from v_snapshot ->> 'declared_window_start'/,
|
||||
/v_profile\.declared_window_end is distinct from v_snapshot ->> 'declared_window_end'/,
|
||||
/v_profile\.uncertainty_before_minutes is distinct from \(v_snapshot ->> 'uncertainty_before_minutes'\)::integer/,
|
||||
/v_profile\.uncertainty_after_minutes is distinct from \(v_snapshot ->> 'uncertainty_after_minutes'\)::integer/,
|
||||
/v_profile\.latitude is distinct from \(v_snapshot ->> 'latitude'\)::double precision/,
|
||||
/v_profile\.longitude is distinct from \(v_snapshot ->> 'longitude'\)::double precision/,
|
||||
/v_profile\.timezone_id is distinct from v_snapshot ->> 'timezone_id'/,
|
||||
/v_profile\.timezone_offset is distinct from \(v_snapshot ->> 'timezone_offset'\)::double precision/,
|
||||
]) {
|
||||
assert.match(freshnessChecks, requiredFreshnessCheck);
|
||||
}
|
||||
});
|
||||
|
||||
test("candidate replay and reselection still require the selected accepted state", () => {
|
||||
const acceptFunction = profileFreshnessMigration.slice(
|
||||
profileFreshnessMigration.indexOf(
|
||||
"create or replace function public.accept_agentic_rectification_candidate_for_case_v2(",
|
||||
),
|
||||
profileFreshnessMigration.indexOf("commit;"),
|
||||
);
|
||||
const selectedStateChecks = acceptFunction.slice(
|
||||
acceptFunction.indexOf("if v_result.selected_candidate_id is not null then"),
|
||||
acceptFunction.indexOf("update public.profiles"),
|
||||
);
|
||||
|
||||
assert.match(selectedStateChecks, /v_result\.selected_time is not distinct from v_candidate\.candidate_time/);
|
||||
assert.match(selectedStateChecks, /v_case\.accepted_time is not distinct from v_candidate\.candidate_time/);
|
||||
assert.match(selectedStateChecks, /v_result\.selection_kind is not distinct from 'user_accepted'/);
|
||||
assert.match(selectedStateChecks, /v_profile\.active_birth_time is not distinct from v_candidate\.candidate_time/);
|
||||
assert.match(selectedStateChecks, /v_profile\.birth_time_status is not distinct from 'accepted'/);
|
||||
assert.match(selectedStateChecks, /v_case\.status is distinct from 'candidate_accepted'/);
|
||||
assert.match(selectedStateChecks, /v_profile\.active_birth_time is distinct from v_result\.selected_time/);
|
||||
assert.match(selectedStateChecks, /v_profile\.birth_time_status is distinct from 'accepted'/);
|
||||
});
|
||||
|
||||
test("decision receipt projection rejects contradictory latest transitions", () => {
|
||||
const composeFunction = profileFreshnessMigration.slice(
|
||||
profileFreshnessMigration.indexOf(
|
||||
"create or replace function public.compose_agentic_rectification_decision_receipt(",
|
||||
),
|
||||
profileFreshnessMigration.indexOf(
|
||||
"create or replace function public.accept_agentic_rectification_candidate_for_case_v2(",
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(composeFunction, /jsonb_typeof\(v_transition\.inference_state -> 'revision'\) is distinct from 'number'/);
|
||||
assert.match(composeFunction, /\(v_transition\.inference_state ->> 'revision'\)::integer is distinct from v_transition\.revision/);
|
||||
assert.match(composeFunction, /jsonb_typeof\(v_transition\.inference_state -> 'candidate_set_id'\) is distinct from 'string'/);
|
||||
assert.match(composeFunction, /v_transition\.inference_state ->> 'candidate_set_id' is distinct from v_transition\.candidate_set_id/);
|
||||
assert.match(composeFunction, /return v_receipt - 'inference_state' - 'decision_state_fingerprint';/);
|
||||
});
|
||||
|
||||
test("candidate acceptance treats the latest inference transition as authoritative", () => {
|
||||
const acceptFunction = profileFreshnessMigration.slice(
|
||||
profileFreshnessMigration.indexOf(
|
||||
"create or replace function public.accept_agentic_rectification_candidate_for_case_v2(",
|
||||
),
|
||||
profileFreshnessMigration.indexOf("commit;"),
|
||||
);
|
||||
const transitionRead = acceptFunction.indexOf("select * into v_latest_transition");
|
||||
const inferenceChoice = acceptFunction.indexOf("when v_latest_transition.id is not null then v_latest_transition.inference_state");
|
||||
const receiptFallback = acceptFunction.indexOf("else v_result.decision_receipt -> 'inference_state'");
|
||||
|
||||
assert.ok(transitionRead >= 0 && transitionRead < inferenceChoice);
|
||||
assert.ok(inferenceChoice >= 0 && inferenceChoice < receiptFallback);
|
||||
assert.match(acceptFunction, /v_latest_transition\.revision is distinct from \(v_inference ->> 'revision'\)::integer/);
|
||||
assert.match(acceptFunction, /v_latest_transition\.candidate_set_id is distinct from v_inference ->> 'candidate_set_id'/);
|
||||
assert.doesNotMatch(
|
||||
acceptFunction,
|
||||
/v_result\.decision_receipt\s*->\s*'inference_state'[\s\S]*is distinct from v_latest_transition\.inference_state/,
|
||||
);
|
||||
});
|
||||
|
||||
test("candidate acceptance validates load-bearing inference fields fail-closed", () => {
|
||||
const acceptFunction = profileFreshnessMigration.slice(
|
||||
profileFreshnessMigration.indexOf(
|
||||
"create or replace function public.accept_agentic_rectification_candidate_for_case_v2(",
|
||||
),
|
||||
profileFreshnessMigration.indexOf("commit;"),
|
||||
);
|
||||
|
||||
assert.match(acceptFunction, /coalesce\(\(item\.value ->> 'time'\) !~ [^\n]+, true\)/);
|
||||
assert.match(acceptFunction, /coalesce\(item\.value ->> 'status', ''\) not in \('active', 'equivalent', 'winner', 'eliminated'\)/);
|
||||
assert.match(acceptFunction, /jsonb_typeof\(item\.value -> 'probability'\) is distinct from 'number'/);
|
||||
assert.match(acceptFunction, /jsonb_typeof\(item\.value -> 'posterior_score'\) is distinct from 'number'/);
|
||||
assert.match(acceptFunction, /jsonb_typeof\(item\.value -> 'cluster_range'\) is distinct from 'array'/);
|
||||
assert.match(acceptFunction, /jsonb_array_length\(item\.value -> 'cluster_range'\) is distinct from 2/);
|
||||
});
|
||||
|
||||
@@ -749,6 +749,26 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
timezone_offset: 8,
|
||||
};
|
||||
const candidateRange = { start_time: "04:50", end_time: "05:10" };
|
||||
const candidateInferenceState = {
|
||||
algorithm_version: "database-local-business-v2",
|
||||
candidate_set_id: "04:50-05:10:04:55,05:07",
|
||||
revision: 0,
|
||||
phase: "candidate_ready",
|
||||
result_status: "completed_with_range",
|
||||
range_start: "04:50",
|
||||
range_end: "05:10",
|
||||
candidates: [
|
||||
{ id: "04:55", time: "04:55", cluster_range: ["04:55", "04:55"], prior_score: 60, posterior_score: 60, probability: 0.6, status: "active", rank: 1, strong_conflict_count: 0 },
|
||||
{ id: "05:07", time: "05:07", cluster_range: ["05:07", "05:07"], prior_score: 40, posterior_score: 40, probability: 0.4, status: "active", rank: 2, strong_conflict_count: 0 },
|
||||
],
|
||||
events: [],
|
||||
probes: [],
|
||||
answered_probes: [],
|
||||
rounds: [],
|
||||
entropy: 1,
|
||||
representative_time: "04:55",
|
||||
credible_range: ["04:55", "05:07"],
|
||||
};
|
||||
fixture.psql(`
|
||||
insert into public.agentic_rectification_cases (
|
||||
id, user_id, session_id, status, skill_name, skill_version,
|
||||
@@ -783,6 +803,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
representative_time: "04:55",
|
||||
overall_confidence: "medium",
|
||||
margin_percent: 20,
|
||||
inference_state: candidateInferenceState,
|
||||
},
|
||||
p_execution_ledger: [
|
||||
{ phase: "candidate.score", status: "completed", engine: "fixture-engine" },
|
||||
|
||||
@@ -218,9 +218,15 @@ test("candidate results restore through the durable Candidate Snapshot API, neve
|
||||
assert.doesNotMatch(chat, /<!--AYANAM_SUGGESTIONS/);
|
||||
});
|
||||
|
||||
test("candidate acceptance is non-billable and happens through the durable case endpoint", () => {
|
||||
test("candidate acceptance is non-billable, mutually exclusive, and continues through the durable case endpoint", () => {
|
||||
assert.match(chat, /\/candidates\/accept/);
|
||||
assert.match(chat, /resultId: candidateResult\.resultId/);
|
||||
assert.match(chat, /if \(!candidateResult \|\| acceptingCandidateId \|\| busy \|\| readonly\) return;/);
|
||||
assert.match(chat, /setAcceptingCandidateId\(candidateId\);[\s\S]*setPending\(true\);/);
|
||||
assert.match(chat, /await loadCaseSnapshot\(\);[\s\S]*choiceContinuationPending\.current = true;/);
|
||||
assert.match(chat, /finally \{[\s\S]*setAcceptingCandidateId\(null\);[\s\S]*setPending\(false\);/);
|
||||
assert.match(chat, /if \(!choiceContinuationPending\.current \|\| busy \|\| readonly\) return;[\s\S]*void send\("read_only", ""\);/);
|
||||
assert.doesNotMatch(chat, /await loadCaseSnapshot\(\);[\s\S]*await send\("read_only", ""\);/);
|
||||
assert.doesNotMatch(chat, /action: "accept_candidate"/);
|
||||
const acceptRoute = readFileSync(
|
||||
new URL("../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", import.meta.url),
|
||||
@@ -610,8 +616,8 @@ test("rectification Agent output stays natural and keeps tool execution silent",
|
||||
assert.match(agent, /本轮做了什么/);
|
||||
assert.match(agent, /完成凭证完全由服务端公开 Activity\/receipt 展示/);
|
||||
assert.match(agent, /禁止只说记下了、会话会保留、以后再继续/);
|
||||
assert.match(agent, /暂时想不到了 \/ 没有更多 \/ 没有了 \/ 没了 \/ 没有其它 \/ 想不起来了 \/ 先这样/);
|
||||
assert.match(agent, /on_user_stop/);
|
||||
assert.match(agent, /没有 \/ 没有了 \/ 没有其它 \/ 想不起来了.*只关闭当前 focus 或当前领域/);
|
||||
assert.match(agent, /结束校正 \/ 不想继续 \/ 直接给结果 \/ 就到这里.*on_user_stop/);
|
||||
assert.match(agent, /skill_verification_report/);
|
||||
assert.match(agent, /D9\/D10 类型对照/);
|
||||
assert.doesNotMatch(agent, /分盘句和宫位表由界面展示/);
|
||||
|
||||
@@ -731,18 +731,42 @@ test("GET choice_card stays after coverage when remaining minutes still split on
|
||||
],
|
||||
},
|
||||
inference_state: {
|
||||
algorithm_version: "rectification-inference-v1",
|
||||
candidate_set_id: "05:00-05:07:05:00,05:06,05:07",
|
||||
revision: 1,
|
||||
phase: "discrimination",
|
||||
result_status: "discriminating",
|
||||
range_start: "05:00",
|
||||
range_end: "05:07",
|
||||
candidates: [
|
||||
{ id: "05:00", time: "05:00", cluster_range: ["05:00", "05:00"], prior_score: 34, posterior_score: 34, probability: 0.34, status: "active", rank: 1, strong_conflict_count: 0 },
|
||||
{ id: "05:06", time: "05:06", cluster_range: ["05:06", "05:06"], prior_score: 33, posterior_score: 33, probability: 0.33, status: "active", rank: 2, strong_conflict_count: 0 },
|
||||
{ id: "05:07", time: "05:07", cluster_range: ["05:07", "05:07"], prior_score: 33, posterior_score: 33, probability: 0.33, status: "active", rank: 3, strong_conflict_count: 0 },
|
||||
],
|
||||
events: [],
|
||||
answered_probes: [{
|
||||
probe_id: "probe:education.2016",
|
||||
semantic_key: "education.2016",
|
||||
candidate_split_hash: "education.2016",
|
||||
answer_class: "yes",
|
||||
classified_from: "evidence",
|
||||
}],
|
||||
probes: [{
|
||||
id: "probe:education.2016",
|
||||
semantic_key: "education.2016",
|
||||
candidate_split_hash: "education.2016",
|
||||
domain: "education",
|
||||
year: 2016,
|
||||
question: "2016 年是否入学?",
|
||||
candidate_ids: ["05:00", "05:06", "05:07"],
|
||||
information_gain: 0,
|
||||
source: "known_event_quality",
|
||||
expected_outcomes: [],
|
||||
}],
|
||||
rounds: [],
|
||||
entropy: 1,
|
||||
representative_time: "05:00",
|
||||
credible_range: ["05:00", "05:07"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -754,7 +778,7 @@ test("GET choice_card stays after coverage when remaining minutes still split on
|
||||
assert.equal(card.prompt, SAMPLE_COPY.prompt);
|
||||
});
|
||||
|
||||
test("GET choice_card stays hidden after 没有了 when selection is allowed", () => {
|
||||
test("GET choice_card stays hidden without a persisted focus after 没有了", () => {
|
||||
const card = choiceCardFromCaseDossier({
|
||||
evidence: [{
|
||||
status: "confirmed",
|
||||
@@ -807,6 +831,28 @@ test("GET choice_card stays hidden after 没有了 when selection is allowed", (
|
||||
assert.equal(card, null);
|
||||
});
|
||||
|
||||
test("dynamic option labels keep the A/B/C/D answer-class contract", () => {
|
||||
const frame = buildChoiceFrame({
|
||||
method_id: "d4_home",
|
||||
ask_theme: "home_change",
|
||||
domain: "relocation",
|
||||
user_prompt_hint: "unused",
|
||||
choice_kind: "existence",
|
||||
style_options: [
|
||||
{ label: "明确没有发生", answer_class: "no" },
|
||||
{ label: "这段记不清楚", answer_class: "unsure" },
|
||||
{ label: "明确发生且时间吻合", answer_class: "yes" },
|
||||
{ label: "发生过但程度较弱", answer_class: "weak_yes" },
|
||||
],
|
||||
}, { probes: [MOVE_PROBE] });
|
||||
const copy = serverOwnedChoiceCopy(frame);
|
||||
assert.ok(copy);
|
||||
assert.equal(copy.option_a, "明确发生且时间吻合");
|
||||
assert.equal(copy.option_b, "发生过但程度较弱");
|
||||
assert.equal(copy.option_c, "明确没有发生");
|
||||
assert.equal(copy.option_d, "这段记不清楚");
|
||||
});
|
||||
|
||||
test("remaining D10 style card uses type-table labels instead of existence", () => {
|
||||
const frame = buildChoiceFrame({
|
||||
method_id: "d10_career",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
askedEventProbeKeysFromLedgerEvidence,
|
||||
askedKeysFromLedgerEvidence,
|
||||
buildCandidateContrastPacket,
|
||||
selectDiscriminatorProbe,
|
||||
@@ -115,6 +116,57 @@ test("missing method coverage stays in fact collection even if scores look separ
|
||||
assert.equal(next.type, "ask_fact_collection");
|
||||
});
|
||||
|
||||
test("dated evidence suppresses an ordinary probe for the same domain and year", () => {
|
||||
const askedKeys = askedEventProbeKeysFromLedgerEvidence([{
|
||||
status: "confirmed",
|
||||
domain: "career",
|
||||
occurredFrom: "2018-01-01",
|
||||
}]);
|
||||
const packet = buildCandidateContrastPacket({
|
||||
candidateSetVersion: "set-a",
|
||||
engineProbes: [{
|
||||
semantic_key: "career.2018.dasha_activation",
|
||||
candidate_split_hash: "career:2018:split",
|
||||
domain: "career",
|
||||
year: 2018,
|
||||
user_meaning: "2018 年前后是否有职业变化?",
|
||||
information_gain: 0.4,
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
|
||||
{ answer_class: "no", supports: ["05:07"], conflicts: ["05:00"] },
|
||||
],
|
||||
}],
|
||||
askedKeys,
|
||||
});
|
||||
assert.equal(selectDiscriminatorProbe(packet), null);
|
||||
});
|
||||
|
||||
test("ordinary education evidence does not suppress a structured D24 probe", () => {
|
||||
const askedKeys = askedEventProbeKeysFromLedgerEvidence([{
|
||||
status: "confirmed",
|
||||
domain: "education",
|
||||
occurredFrom: "2016-09-01",
|
||||
}]);
|
||||
const packet = buildCandidateContrastPacket({
|
||||
candidateSetVersion: "set-a",
|
||||
engineProbes: [{
|
||||
semantic_key: "varga.d24",
|
||||
candidate_split_hash: "d24:split",
|
||||
domain: "education",
|
||||
year: 2016,
|
||||
choice_kind: "varga_style",
|
||||
user_meaning: "这段学习经历更接近哪种过程?",
|
||||
information_gain: 0.8,
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
|
||||
{ answer_class: "no", supports: ["05:07"], conflicts: ["05:00"] },
|
||||
],
|
||||
}],
|
||||
askedKeys,
|
||||
});
|
||||
assert.equal(selectDiscriminatorProbe(packet)?.semanticKey, "varga.d24");
|
||||
});
|
||||
|
||||
test("whole-window D9/D10 signs do not synthesize a discriminator without remaining-minute splits", () => {
|
||||
const packet = buildCandidateContrastPacket({
|
||||
candidateSetVersion: "set-a",
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
publicDecisionFields,
|
||||
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||||
import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts";
|
||||
import { overlayPublicDecision } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||||
import { contrastPacketFromDossier, overlayPublicDecision } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||||
import { conversationalSessionOutcome } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
|
||||
const SEPARATED = [
|
||||
@@ -65,8 +65,9 @@ test("public decision fields are derived from decideRectification", () => {
|
||||
representative_time: decision.representativeTime,
|
||||
credible_range: decision.credibleRange,
|
||||
});
|
||||
assert.equal(overlayPublicDecision({ selectionAllowed: true }, decision).selectionAllowed, fields.selection_allowed);
|
||||
assert.equal(overlayPublicDecision({ selectionAllowed: true }, decision).validated, fields.validated);
|
||||
const publicOverlay = overlayPublicDecision({ selectionAllowed: true }, decision);
|
||||
assert.equal(publicOverlay.selectionAllowed, false);
|
||||
assert.equal(publicOverlay.validated, fields.validated);
|
||||
assert.equal(decideNextAction(input).type, decision.nextAction);
|
||||
assert.equal(conversationalSessionOutcome({
|
||||
selectionAllowed: decision.selectionAllowed,
|
||||
@@ -81,6 +82,209 @@ test("public decision fields are derived from decideRectification", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("recorded education evidence does not suppress an unasked D24 discriminator", () => {
|
||||
const packet = contrastPacketFromDossier({
|
||||
evidence: [{
|
||||
status: "confirmed",
|
||||
domain: "education",
|
||||
datePrecision: "year",
|
||||
occurredFrom: "2016-01-01",
|
||||
occurredTo: null,
|
||||
eventKind: "education_exam",
|
||||
summary: "2016 年考试发挥失常",
|
||||
}],
|
||||
conversationSummary: { activeFocus: null, declinedSkippedTopics: [] },
|
||||
latestResult: {
|
||||
resultId: "result-d24",
|
||||
candidates: [
|
||||
{ candidateId: "c-0500", time: "05:00", relativeSupport: 20 },
|
||||
{ candidateId: "c-0507", time: "05:07", relativeSupport: 15 },
|
||||
{ candidateId: "c-0512", time: "05:12", relativeSupport: 15 },
|
||||
],
|
||||
decisionReceipt: {
|
||||
inference_state: {
|
||||
algorithm_version: "rectification-inference-v1",
|
||||
candidate_set_id: "05:00-05:12:05:00,05:07,05:12",
|
||||
revision: 0,
|
||||
phase: "discrimination",
|
||||
result_status: "discriminating",
|
||||
range_start: "05:00",
|
||||
range_end: "05:12",
|
||||
candidates: [
|
||||
{ id: "05:00", time: "05:00", cluster_range: ["05:00", "05:00"], prior_score: 20, posterior_score: 20, probability: 0.4, status: "active", rank: 1, strong_conflict_count: 0 },
|
||||
{ id: "05:07", time: "05:07", cluster_range: ["05:07", "05:07"], prior_score: 15, posterior_score: 15, probability: 0.3, status: "active", rank: 2, strong_conflict_count: 0 },
|
||||
{ id: "05:12", time: "05:12", cluster_range: ["05:12", "05:12"], prior_score: 15, posterior_score: 15, probability: 0.3, status: "active", rank: 3, strong_conflict_count: 0 },
|
||||
],
|
||||
events: [], probes: [], answered_probes: [], rounds: [], entropy: 1,
|
||||
representative_time: "05:00",
|
||||
credible_range: ["05:00", "05:12"],
|
||||
},
|
||||
window_scan: {
|
||||
scanned: true,
|
||||
d9_lagna_count: 1,
|
||||
d10_lagna_count: 1,
|
||||
d24_lagna_count: 3,
|
||||
transitions: [
|
||||
{ layer: "d24", at: "05:07", from_sign: "白羊座", to_sign: "金牛座" },
|
||||
{ layer: "d24", at: "05:12", from_sign: "金牛座", to_sign: "双子座" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
case: { acceptedTime: null },
|
||||
});
|
||||
|
||||
assert.equal(packet.probes[0]?.semanticKey.startsWith("varga.d24."), true);
|
||||
assert.equal(packet.probes[0]?.informationGain > 1, true);
|
||||
assert.deepEqual(packet.probes[0]?.expectedOutcomes.map((row) => row.supportsCandidateIds), [
|
||||
["05:00"],
|
||||
["05:07"],
|
||||
["05:12"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("public candidate cards follow the inference ranking and hide an inconsistent state", () => {
|
||||
const decision = decideRectification({
|
||||
methodCoverageAll: true,
|
||||
trainingGateOpen: true,
|
||||
candidateScores: SEPARATED,
|
||||
holdoutValidation: "passed",
|
||||
});
|
||||
const base = {
|
||||
candidates: [
|
||||
{ candidateId: "c-0507", time: "05:07", rank: 1, relativeSupport: 13, tiedMinuteCount: 1 },
|
||||
{ candidateId: "c-0500", time: "05:00", rank: 2, relativeSupport: 12, tiedMinuteCount: 1 },
|
||||
{ candidateId: "c-0515", time: "05:15", rank: 3, relativeSupport: 10, tiedMinuteCount: 1 },
|
||||
],
|
||||
representativeTime: "05:07",
|
||||
decisionReceipt: {
|
||||
inference_state: {
|
||||
algorithm_version: "rectification-inference-v1",
|
||||
candidate_set_id: "05:00-05:15:05:00,05:07,05:15",
|
||||
revision: 2,
|
||||
phase: "discrimination",
|
||||
result_status: "discriminating",
|
||||
range_start: "05:00",
|
||||
range_end: "05:15",
|
||||
candidates: [
|
||||
{ id: "05:07", time: "05:07", cluster_range: ["05:07", "05:07"], prior_score: 13, posterior_score: 15, probability: 0.4, status: "active", rank: 2, strong_conflict_count: 0 },
|
||||
{ id: "05:00", time: "05:00", cluster_range: ["05:00", "05:00"], prior_score: 12, posterior_score: 20, probability: 0.6, status: "active", rank: 1, strong_conflict_count: 0 },
|
||||
{ id: "05:15", time: "05:15", cluster_range: ["05:15", "05:15"], prior_score: 10, posterior_score: 30, probability: 0, status: "eliminated", rank: 3, strong_conflict_count: 1 },
|
||||
],
|
||||
events: [], probes: [], answered_probes: [], rounds: [], entropy: 0.67,
|
||||
representative_time: "05:00",
|
||||
credible_range: ["05:00", "05:07"],
|
||||
},
|
||||
},
|
||||
};
|
||||
const missingInference = overlayPublicDecision({
|
||||
...base,
|
||||
decisionReceipt: {},
|
||||
}, decision);
|
||||
assert.deepEqual(missingInference.candidates, []);
|
||||
assert.equal(missingInference.representativeTime, null);
|
||||
assert.equal(missingInference.selectionAllowed, false);
|
||||
assert.equal(missingInference.can_adopt, false);
|
||||
|
||||
const projected = overlayPublicDecision(base, decision);
|
||||
assert.deepEqual(projected.candidates.map((item) => item.time), ["05:00", "05:07"]);
|
||||
assert.deepEqual(projected.candidates.map((item) => item.relativeSupport), [20, 15]);
|
||||
assert.equal(projected.representativeTime, "05:00");
|
||||
assert.deepEqual(projected.credible_range, ["05:00", "05:07"]);
|
||||
|
||||
const incompleteCandidateStates = [
|
||||
base.decisionReceipt.inference_state.candidates.slice(0, 2),
|
||||
[
|
||||
...base.decisionReceipt.inference_state.candidates.slice(0, 2),
|
||||
{ ...base.decisionReceipt.inference_state.candidates[0], status: "eliminated", probability: 0 },
|
||||
],
|
||||
];
|
||||
for (const candidates of incompleteCandidateStates) {
|
||||
const incomplete = overlayPublicDecision({
|
||||
...base,
|
||||
decisionReceipt: {
|
||||
inference_state: {
|
||||
...base.decisionReceipt.inference_state,
|
||||
candidates,
|
||||
},
|
||||
},
|
||||
}, decision);
|
||||
assert.deepEqual(incomplete.candidates, []);
|
||||
assert.equal(incomplete.representativeTime, null);
|
||||
assert.equal(incomplete.selectionAllowed, false);
|
||||
assert.equal(incomplete.can_adopt, false);
|
||||
}
|
||||
|
||||
const malformedInferenceStates = [
|
||||
{ ...base.decisionReceipt.inference_state, revision: undefined },
|
||||
{ ...base.decisionReceipt.inference_state, candidate_set_id: "" },
|
||||
{ ...base.decisionReceipt.inference_state, candidate_set_id: "wrong-set" },
|
||||
{ ...base.decisionReceipt.inference_state, range_start: undefined },
|
||||
{ ...base.decisionReceipt.inference_state, events: [null] },
|
||||
{ ...base.decisionReceipt.inference_state, probes: [{ id: "broken" }] },
|
||||
{ ...base.decisionReceipt.inference_state, answered_probes: [{ probe_id: "broken" }] },
|
||||
{ ...base.decisionReceipt.inference_state, rounds: [{ round: 1 }] },
|
||||
{
|
||||
...base.decisionReceipt.inference_state,
|
||||
candidates: base.decisionReceipt.inference_state.candidates.map((candidate, index) => (
|
||||
index === 0 ? { ...candidate, status: undefined } : candidate
|
||||
)),
|
||||
},
|
||||
{
|
||||
...base.decisionReceipt.inference_state,
|
||||
candidates: base.decisionReceipt.inference_state.candidates.map((candidate, index) => (
|
||||
index === 0 ? { ...candidate, posterior_score: undefined } : candidate
|
||||
)),
|
||||
},
|
||||
{
|
||||
...base.decisionReceipt.inference_state,
|
||||
candidates: base.decisionReceipt.inference_state.candidates.map((candidate, index) => (
|
||||
index === 0 ? { ...candidate, cluster_range: undefined } : candidate
|
||||
)),
|
||||
},
|
||||
];
|
||||
for (const inferenceState of malformedInferenceStates) {
|
||||
const malformed = overlayPublicDecision({
|
||||
...base,
|
||||
decisionReceipt: { inference_state: inferenceState },
|
||||
}, decision);
|
||||
assert.deepEqual(malformed.candidates, []);
|
||||
assert.equal(malformed.representativeTime, null);
|
||||
assert.equal(malformed.selectionAllowed, false);
|
||||
assert.equal(malformed.can_adopt, false);
|
||||
}
|
||||
|
||||
const inconsistent = overlayPublicDecision({
|
||||
...base,
|
||||
decisionReceipt: {
|
||||
inference_state: {
|
||||
...base.decisionReceipt.inference_state,
|
||||
representative_time: "05:15",
|
||||
},
|
||||
},
|
||||
}, decision);
|
||||
assert.deepEqual(inconsistent.candidates, []);
|
||||
assert.equal(inconsistent.selectionAllowed, false);
|
||||
assert.equal(inconsistent.can_adopt, false);
|
||||
|
||||
for (const credibleRange of [["05:00", "05:00"], ["05:07", "05:00"]] as const) {
|
||||
const rangeMismatch = overlayPublicDecision({
|
||||
...base,
|
||||
decisionReceipt: {
|
||||
inference_state: {
|
||||
...base.decisionReceipt.inference_state,
|
||||
credible_range: credibleRange,
|
||||
},
|
||||
},
|
||||
}, decision);
|
||||
assert.deepEqual(rangeMismatch.candidates, []);
|
||||
assert.equal(rangeMismatch.representativeTime, null);
|
||||
assert.equal(rangeMismatch.credible_range, null);
|
||||
assert.equal(rangeMismatch.selectionAllowed, false);
|
||||
assert.equal(rangeMismatch.can_adopt, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("interview, choice, refresh and next-action all call the same reducer", () => {
|
||||
const interview = readSource("../src/lib/rectification-agentic/v9/interview-state.ts");
|
||||
const adapter = readSource("../src/lib/rectification-agentic/v9/decision-from-dossier.ts");
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
import {
|
||||
CASE_ID,
|
||||
CANDIDATE_ID,
|
||||
EVIDENCE_ID,
|
||||
FOCUS_ID,
|
||||
RESULT_ID,
|
||||
SECOND_CANDIDATE_ID,
|
||||
@@ -577,10 +576,7 @@ test("D9 differ keeps sign names for the type-table report and still forbids uni
|
||||
evidence: CLASSIC_COVERAGE,
|
||||
observations,
|
||||
});
|
||||
assert.equal(plan.next_followup?.source, "varga_observation");
|
||||
assert.equal(plan.next_followup?.ask_theme, "relationship_style");
|
||||
assert.match(plan.next_followup?.user_prompt_hint ?? "", /自己写一句追问/);
|
||||
assert.equal(plan.next_followup?.choice_frame?.choice_mode, "A/B/C/D");
|
||||
assert.equal(plan.next_followup, null);
|
||||
assert.doesNotMatch(JSON.stringify(plan), UNIQUE_MINUTE_COPY);
|
||||
});
|
||||
|
||||
@@ -1077,6 +1073,61 @@ test("public tool surface stays at 13 and new cases bind 10.0.12", () => {
|
||||
assert.doesNotMatch(skill, /KP 政策跳过不挡提出门/);
|
||||
});
|
||||
|
||||
test("Mastra hides active candidates when the receipt range excludes one of them", () => {
|
||||
const candidates = [
|
||||
{ candidateId: CANDIDATE_ID, time: "05:00", rank: 1, relativeSupport: 20, tiedMinuteCount: 1 },
|
||||
{ candidateId: SECOND_CANDIDATE_ID, time: "05:07", rank: 2, relativeSupport: 15, tiedMinuteCount: 1 },
|
||||
];
|
||||
const inferenceState = {
|
||||
algorithm_version: "rectification-inference-v1",
|
||||
candidate_set_id: "05:00-05:07:05:00,05:07",
|
||||
revision: 2,
|
||||
phase: "discrimination",
|
||||
result_status: "credible_range",
|
||||
range_start: "05:00",
|
||||
range_end: "05:07",
|
||||
candidates: [
|
||||
{ id: CANDIDATE_ID, time: "05:00", cluster_range: ["05:00", "05:00"], prior_score: 20, posterior_score: 20, probability: 0.57, status: "active", rank: 1, strong_conflict_count: 0 },
|
||||
{ id: SECOND_CANDIDATE_ID, time: "05:07", cluster_range: ["05:07", "05:07"], prior_score: 15, posterior_score: 15, probability: 0.43, status: "active", rank: 2, strong_conflict_count: 0 },
|
||||
],
|
||||
events: [], probes: [], answered_probes: [], rounds: [], entropy: 0.98,
|
||||
representative_time: "05:00",
|
||||
credible_range: ["05:00", "05:07"],
|
||||
};
|
||||
const latest = {
|
||||
resultId: RESULT_ID,
|
||||
candidates,
|
||||
selectionAllowed: true,
|
||||
confirmationAllowed: false,
|
||||
representativeTime: "05:00",
|
||||
selectedTime: null,
|
||||
selectionKind: null,
|
||||
algorithmVersion: "rectification-v5",
|
||||
decisionReceipt: { inference_state: inferenceState },
|
||||
};
|
||||
const session = {
|
||||
methods: buildMethodFollowupPlan({ evidence: CLASSIC_COVERAGE }).methods,
|
||||
userStopped: true,
|
||||
candidateScores: candidates.map((item) => ({ time: item.time, score: item.relativeSupport })),
|
||||
holdoutValidation: "passed" as const,
|
||||
snapshotCurrent: true,
|
||||
trainingGateOpen: true,
|
||||
};
|
||||
const valid = latestResultToolProjection(latest, session);
|
||||
assert.deepEqual((valid.candidates as typeof candidates).map((item) => item.time), ["05:00", "05:07"]);
|
||||
assert.equal(valid.selection_allowed, true);
|
||||
|
||||
const invalid = latestResultToolProjection({
|
||||
...latest,
|
||||
decisionReceipt: {
|
||||
inference_state: { ...inferenceState, credible_range: ["05:00", "05:00"] },
|
||||
},
|
||||
}, session);
|
||||
assert.deepEqual(invalid.candidates, []);
|
||||
assert.equal(invalid.representative_time, null);
|
||||
assert.equal(invalid.selection_allowed, false);
|
||||
});
|
||||
|
||||
test("family then occupation then horary follow the method plan without appearance or marks", () => {
|
||||
const afterFamily = buildMethodFollowupPlan({
|
||||
evidence: [
|
||||
@@ -1295,6 +1346,34 @@ test("accepted representative time without remaining probes hands off to consult
|
||||
assert.doesNotMatch(JSON.stringify({ plan, action }), UNIQUE_MINUTE_COPY);
|
||||
});
|
||||
|
||||
test("confirmed relationship evidence skips generic D9 followups unless a real probe is bound", () => {
|
||||
const evidence = CLASSIC_COVERAGE;
|
||||
const precision = buildMethodFollowupPlan({ evidence, precisionStage: "d9_refine" });
|
||||
const varga = buildMethodFollowupPlan({
|
||||
evidence,
|
||||
observations: [{ layer: "d9", candidates_differ: true, ask_theme: "relationship_style" }],
|
||||
});
|
||||
assert.notEqual(precision.next_followup?.domain, "relationship");
|
||||
assert.notEqual(varga.next_followup?.domain, "relationship");
|
||||
|
||||
const probed = buildMethodFollowupPlan({
|
||||
evidence,
|
||||
precisionStage: "d9_refine",
|
||||
eventProbes: [{
|
||||
...CAREER_CONFLICT_PROBE,
|
||||
year: 2021,
|
||||
year_label: "2021 年前后",
|
||||
domain: "relationship",
|
||||
event_family: "关系状态或相处方式明显变化",
|
||||
semantic_key: "relationship.2021.dasha_activation",
|
||||
candidate_split_hash: "set-test:relationship:2021",
|
||||
}],
|
||||
});
|
||||
assert.equal(probed.next_followup?.domain, "relationship");
|
||||
assert.equal(probed.next_followup?.source, "event_probe");
|
||||
assert.equal(probed.next_followup?.semantic_key, "relationship.2021.dasha_activation");
|
||||
});
|
||||
|
||||
test("d9_refine after relationship still asks uncovered career first", () => {
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: [
|
||||
@@ -1691,10 +1770,17 @@ test("coverage-complete tie with no remaining split offers a provisional range",
|
||||
assert.equal(action.id, "offer_provisional_range");
|
||||
});
|
||||
|
||||
test("没有了 is a user stop", () => {
|
||||
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "没有了" }]), true);
|
||||
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "没了" }]), true);
|
||||
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "想不起来了" }]), true);
|
||||
test("only explicit rectification exit language is a global user stop", () => {
|
||||
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "没有" }]), false);
|
||||
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "没有了" }]), false);
|
||||
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "想不起来了" }]), false);
|
||||
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "结束校正" }]), true);
|
||||
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "我不想继续了" }]), true);
|
||||
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "直接给结果吧" }]), true);
|
||||
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "就到这里" }]), true);
|
||||
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "我不是不想继续,只是想先补工作" }]), false);
|
||||
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "不要直接给结果,我还想补一件事" }]), false);
|
||||
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "还没到这里结束,我继续说" }]), false);
|
||||
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "还有一件升学" }]), false);
|
||||
assert.equal(conversationalSessionOutcome({
|
||||
selectionAllowed: true,
|
||||
@@ -2034,7 +2120,7 @@ test("user stop with selection_allowed may offer the escape hatch", async () =>
|
||||
turns: [{
|
||||
id: TURN_ID,
|
||||
role: "user",
|
||||
text: "暂时想不到了",
|
||||
text: "结束校正",
|
||||
status: "completed",
|
||||
created_at: "2026-08-12T10:00:00.000Z",
|
||||
completed_at: "2026-08-12T10:00:05.000Z",
|
||||
@@ -2047,6 +2133,28 @@ test("user stop with selection_allowed may offer the escape hatch", async () =>
|
||||
{ candidate_id: CANDIDATE_ID, rank: 1, time: "04:48", relative_support: 58, tied_minute_count: 1 },
|
||||
{ candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "04:49", relative_support: 42, tied_minute_count: 2 },
|
||||
],
|
||||
decisionReceipt: {
|
||||
inference_state: {
|
||||
algorithm_version: "rectification-inference-v1",
|
||||
candidate_set_id: "04:48-04:49:04:48,04:49",
|
||||
revision: 1,
|
||||
phase: "discrimination",
|
||||
result_status: "credible_range",
|
||||
range_start: "04:48",
|
||||
range_end: "04:49",
|
||||
candidates: [
|
||||
{ id: CANDIDATE_ID, time: "04:48", cluster_range: ["04:48", "04:48"], prior_score: 58, posterior_score: 58, probability: 0.58, status: "active", rank: 1, strong_conflict_count: 0 },
|
||||
{ id: SECOND_CANDIDATE_ID, time: "04:49", cluster_range: ["04:49", "04:49"], prior_score: 42, posterior_score: 42, probability: 0.42, status: "active", rank: 2, strong_conflict_count: 0 },
|
||||
],
|
||||
events: [],
|
||||
probes: [],
|
||||
answered_probes: [],
|
||||
rounds: [],
|
||||
entropy: 0.98,
|
||||
representative_time: "04:48",
|
||||
credible_range: ["04:48", "04:49"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
transition_agentic_rectification_case_status: () => ({
|
||||
|
||||
@@ -4,7 +4,6 @@ import test from "node:test";
|
||||
import { applyProbeOutcome } from "../src/lib/rectification-agentic/core/apply-probe-outcome.ts";
|
||||
import {
|
||||
answersFromEvidence,
|
||||
applyAnswerToState,
|
||||
applySupersedeAnswer,
|
||||
buildInferenceState,
|
||||
replayInferenceState,
|
||||
@@ -24,7 +23,6 @@ import { fileURLToPath } from "node:url";
|
||||
import { clusterEquivalentCandidates } from "../src/lib/rectification-agentic/core/cluster-candidates.ts";
|
||||
import { evaluateConvergence } from "../src/lib/rectification-agentic/core/convergence-evaluator.ts";
|
||||
import { isDuplicateProbe } from "../src/lib/rectification-agentic/core/duplicate-probes.ts";
|
||||
import { entropyFromScores } from "../src/lib/rectification-agentic/core/entropy.ts";
|
||||
import { selectHighestGainProbe } from "../src/lib/rectification-agentic/core/select-probe.ts";
|
||||
import { holdoutEventIds, splitHoldoutEvents } from "../src/lib/rectification-agentic/core/split-holdout.ts";
|
||||
import type { ConflictProbe, InferenceCandidate, ProbeAnswer } from "../src/lib/rectification-agentic/core/types.ts";
|
||||
@@ -93,47 +91,104 @@ test("discriminating result does not stay in event_collection", () => {
|
||||
assert.equal(state.phase, "discrimination");
|
||||
});
|
||||
|
||||
test("an informative answer lowers entropy and cannot revive an eliminated candidate", () => {
|
||||
const conflict = probe({
|
||||
id: "p1",
|
||||
test("repeated strong conflicts eliminate a high-support candidate without double-counting replay", () => {
|
||||
const conflicts = [2015, 2016, 2017].map((year, index) => probe({
|
||||
id: `p${index + 1}`,
|
||||
year,
|
||||
gain: 0.3,
|
||||
yesSupports: ["05:00"],
|
||||
yesConflicts: ["05:10"],
|
||||
});
|
||||
const before = { "04:50": 10, "05:00": 10, "05:10": 10 };
|
||||
const first = applyProbeOutcome(before, conflict, "yes");
|
||||
}));
|
||||
const first = applyProbeOutcome({ "05:00": 10, "05:10": 10 }, conflicts[0]!, "yes");
|
||||
assert.equal(first.kind, "informative");
|
||||
assert.ok(entropyFromScores(first.scores) < entropyFromScores(before));
|
||||
assert.ok(first.eliminated_ids.includes("05:10"));
|
||||
const next = buildInferenceState({
|
||||
range_start: "04:50",
|
||||
assert.deepEqual(first.eliminated_ids, []);
|
||||
assert.equal(first.strong_conflict_counts["05:10"], 1);
|
||||
|
||||
const answers: ProbeAnswer[] = conflicts.map((item) => ({
|
||||
probe_id: item.id,
|
||||
semantic_key: item.semantic_key,
|
||||
candidate_split_hash: item.candidate_split_hash,
|
||||
answer_class: "yes",
|
||||
classified_from: "choice",
|
||||
}));
|
||||
const state = buildInferenceState({
|
||||
range_start: "05:00",
|
||||
range_end: "05:10",
|
||||
candidates: [
|
||||
{ id: "04:50", time: "04:50", relative_support: 10 },
|
||||
{ id: "05:00", time: "05:00", relative_support: 10 },
|
||||
{ id: "05:10", time: "05:10", relative_support: 10 },
|
||||
{ id: "05:00", time: "05:00", relative_support: 34 },
|
||||
{ id: "05:10", time: "05:10", relative_support: 58 },
|
||||
],
|
||||
events: [
|
||||
{ id: "e1", domain: "education", year: 2016, precision: "month" },
|
||||
{ id: "e1", domain: "education", year: 2018, precision: "month" },
|
||||
{ id: "e2", domain: "career", year: 2019, precision: "year" },
|
||||
{ id: "e3", domain: "relationship", year: 2021, precision: "year" },
|
||||
{ id: "e4", domain: "family", year: 2023, precision: "year" },
|
||||
],
|
||||
probes: [conflict],
|
||||
answered_probes: [{
|
||||
probe_id: "p1",
|
||||
semantic_key: conflict.semantic_key,
|
||||
candidate_split_hash: conflict.candidate_split_hash,
|
||||
answer_class: "no",
|
||||
classified_from: "choice",
|
||||
}],
|
||||
probes: conflicts,
|
||||
answered_probes: answers,
|
||||
});
|
||||
assert.equal(next.candidates.find((item) => item.id === "05:00")?.status, "eliminated");
|
||||
assert.equal(next.last_inference_round?.kind, "informative");
|
||||
assert.ok(next.last_inference_round?.score_deltas);
|
||||
assert.notDeepEqual(next.last_inference_round?.scores_before, next.last_inference_round?.scores_after);
|
||||
const revived = applyAnswerToState(next, "p1", "yes");
|
||||
assert.equal(revived.candidates.find((item) => item.id === "05:00")?.status, "eliminated");
|
||||
const rejected = state.candidates.find((item) => item.id === "05:10");
|
||||
assert.equal(rejected?.strong_conflict_count, 3);
|
||||
assert.equal(rejected?.status, "eliminated");
|
||||
assert.notEqual(state.result_status, "converged");
|
||||
|
||||
const rebuilt = buildInferenceState({
|
||||
range_start: state.range_start,
|
||||
range_end: state.range_end,
|
||||
candidates: state.candidates.map((item) => ({
|
||||
id: item.id,
|
||||
time: item.time,
|
||||
relative_support: item.prior_score,
|
||||
})),
|
||||
events: state.events,
|
||||
probes: state.probes,
|
||||
previous: state,
|
||||
});
|
||||
assert.equal(rebuilt.candidates.find((item) => item.id === "05:10")?.strong_conflict_count, 3);
|
||||
assert.equal(rebuilt.candidates.find((item) => item.id === "05:10")?.status, "eliminated");
|
||||
|
||||
const replayed = replayInferenceState(state, answers);
|
||||
assert.equal(replayed.candidates.find((item) => item.id === "05:10")?.strong_conflict_count, 3);
|
||||
assert.equal(replayed.candidates.find((item) => item.id === "05:10")?.status, "eliminated");
|
||||
|
||||
const corrected = applySupersedeAnswer(state, conflicts[2]!.id, "unsure");
|
||||
assert.equal(corrected.candidates.find((item) => item.id === "05:10")?.strong_conflict_count, 2);
|
||||
assert.notEqual(corrected.candidates.find((item) => item.id === "05:10")?.status, "eliminated");
|
||||
});
|
||||
|
||||
test("strong-conflict elimination always leaves one survivor and cannot declare it converged", () => {
|
||||
const conflicts = [2015, 2016, 2017].map((year, index) => probe({
|
||||
id: `all-conflict-${index + 1}`,
|
||||
year,
|
||||
gain: 0.3,
|
||||
yesSupports: [],
|
||||
yesConflicts: ["05:00", "05:10"],
|
||||
}));
|
||||
const state = buildInferenceState({
|
||||
range_start: "05:00",
|
||||
range_end: "05:10",
|
||||
candidates: [
|
||||
{ id: "05:00", time: "05:00", relative_support: 58 },
|
||||
{ id: "05:10", time: "05:10", relative_support: 34 },
|
||||
],
|
||||
events: [
|
||||
{ id: "e1", domain: "education", year: 2018, precision: "month" },
|
||||
{ id: "e2", domain: "career", year: 2019, precision: "year" },
|
||||
{ id: "e3", domain: "relationship", year: 2021, precision: "year" },
|
||||
{ id: "e4", domain: "family", year: 2023, precision: "year" },
|
||||
],
|
||||
probes: conflicts,
|
||||
answered_probes: conflicts.map((item) => ({
|
||||
probe_id: item.id,
|
||||
semantic_key: item.semantic_key,
|
||||
candidate_split_hash: item.candidate_split_hash,
|
||||
answer_class: "yes",
|
||||
classified_from: "choice",
|
||||
})),
|
||||
});
|
||||
assert.equal(state.candidates.filter((item) => item.status !== "eliminated").length, 1);
|
||||
assert.equal(state.candidates.every((item) => item.strong_conflict_count === 3), true);
|
||||
assert.notEqual(state.result_status, "converged");
|
||||
});
|
||||
|
||||
test("an unsure answer is low-information and the next probe cannot reuse the same split", () => {
|
||||
@@ -393,7 +448,11 @@ test("C without new evidence updates the posterior immediately and D only marks
|
||||
assert.equal(denied.answerClass, "no");
|
||||
assert.equal(denied.state.revision, state.revision + 1);
|
||||
assert.notDeepEqual(posteriorMap(denied.state.candidates), posteriorMap(state.candidates));
|
||||
assert.equal(denied.state.candidates.find((item) => item.id === "05:00")?.status, "eliminated");
|
||||
assert.equal(denied.state.candidates.find((item) => item.id === "05:00")?.status, "active");
|
||||
assert.ok(
|
||||
(denied.state.candidates.find((item) => item.id === "05:00")?.posterior_score ?? 0)
|
||||
< (denied.state.candidates.find((item) => item.id === "05:10")?.posterior_score ?? 0),
|
||||
);
|
||||
assert.ok(denied.state.entropy < state.entropy);
|
||||
assert.equal(denied.state.answered_probes.some((item) => item.semantic_key === conflict.semantic_key), true);
|
||||
|
||||
@@ -811,6 +870,20 @@ test("evidence fingerprint can stay put while decision-state fingerprint and pos
|
||||
(staleReceipt.inference_state as { candidates: unknown }).candidates,
|
||||
);
|
||||
assert.equal(composed.decision_state_fingerprint, afterFp);
|
||||
|
||||
const revisionMismatch = composeInferenceReceipt(staleReceipt, {
|
||||
resultId: "result-1",
|
||||
revision: after.state.revision + 1,
|
||||
probeId: conflict.id,
|
||||
reason: "choice",
|
||||
decisionStateFingerprint: afterFp,
|
||||
inferenceState: after.state,
|
||||
posteriorBefore: posteriorMap(state.candidates),
|
||||
posteriorAfter: posteriorMap(after.state.candidates),
|
||||
scoreDeltas: {},
|
||||
}, "result-1");
|
||||
assert.equal(revisionMismatch.inference_state, state);
|
||||
assert.equal(revisionMismatch.decision_state_fingerprint, undefined);
|
||||
});
|
||||
|
||||
test("persisted focus can answer a lower-gain probe while conflicting schema identities stay stale", () => {
|
||||
|
||||
@@ -122,6 +122,7 @@ test("PR-4 candidate decisions use server UUIDs, receipt-derived gates and separ
|
||||
birth_date: "1997-08-08",
|
||||
latitude: 36.420487,
|
||||
longitude: 114.209936,
|
||||
timezone_id: "Asia/Shanghai",
|
||||
timezone_offset: 8,
|
||||
birth_time_source: "family_exact",
|
||||
reported_birth_time: "05:00",
|
||||
@@ -193,6 +194,26 @@ test("PR-4 candidate decisions use server UUIDs, receipt-derived gates and separ
|
||||
},
|
||||
{ rank: 2, time: "05:08", relative_support: 40, tied_minute_count: 1 },
|
||||
];
|
||||
const inferenceState = {
|
||||
algorithm_version: "rectification-pr4-fixture-v1",
|
||||
candidate_set_id: "04:50-05:10:05:07,05:08",
|
||||
revision: 0,
|
||||
phase: "candidate_ready",
|
||||
result_status: "completed_with_range",
|
||||
range_start: "04:50",
|
||||
range_end: "05:10",
|
||||
candidates: [
|
||||
{ id: "05:07", time: "05:07", cluster_range: ["05:07", "05:07"], prior_score: 60, posterior_score: 60, probability: 0.6, status: "active", rank: 1, strong_conflict_count: 0 },
|
||||
{ id: "05:08", time: "05:08", cluster_range: ["05:08", "05:08"], prior_score: 40, posterior_score: 40, probability: 0.4, status: "active", rank: 2, strong_conflict_count: 0 },
|
||||
],
|
||||
events: [],
|
||||
probes: [],
|
||||
answered_probes: [],
|
||||
rounds: [],
|
||||
entropy: 1,
|
||||
representative_time: "05:07",
|
||||
credible_range: ["05:07", "05:08"],
|
||||
};
|
||||
const decisionReceipt = {
|
||||
display_allowed: true,
|
||||
accept_allowed: true,
|
||||
@@ -201,6 +222,7 @@ test("PR-4 candidate decisions use server UUIDs, receipt-derived gates and separ
|
||||
overall_confidence: "high",
|
||||
margin_percent: 20,
|
||||
representative_candidate_id: fakeCallerCandidateId,
|
||||
inference_state: inferenceState,
|
||||
};
|
||||
const executionLedger = [
|
||||
{ phase: "candidate.score", status: "completed", engine: "fixture-engine" },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||||
import { persistServerOwnedFocus, shouldSkipDiscriminatorFollowup, stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
||||
import { buildChoiceFrame, serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts";
|
||||
import type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
@@ -202,12 +203,14 @@ test("contrast probe is not replaced by an already-answered education quality pr
|
||||
caseId: CASE_ID,
|
||||
activeFocus: null,
|
||||
decisionReceipt: {
|
||||
inference_state: {
|
||||
answered_probes: [{
|
||||
probe_id: "probe:education.2016",
|
||||
semantic_key: "education.2016",
|
||||
answer_class: "yes",
|
||||
}],
|
||||
inference_state: buildInferenceState({
|
||||
range_start: "05:00",
|
||||
range_end: "05:20",
|
||||
candidates: [
|
||||
{ id: "05:00", time: "05:00", relative_support: 10 },
|
||||
{ id: "05:20", time: "05:20", relative_support: 10 },
|
||||
],
|
||||
events: [],
|
||||
probes: [{
|
||||
id: "probe:education.2016",
|
||||
semantic_key: "education.2016",
|
||||
@@ -234,7 +237,7 @@ test("contrast probe is not replaced by an already-answered education quality pr
|
||||
information_gain: 0.16,
|
||||
source: "varga_contrast",
|
||||
}],
|
||||
},
|
||||
}),
|
||||
},
|
||||
followup,
|
||||
});
|
||||
|
||||
@@ -83,6 +83,21 @@ test("loadV9RectificationProfile uses a server-owned radius around reported time
|
||||
assert.ok(!("password" in profile.baseline));
|
||||
});
|
||||
|
||||
test("profile fingerprint ignores display labels and prior accepted minutes", async () => {
|
||||
const first = await loadV9RectificationProfile(fakeAccounting({
|
||||
profile: completeProfile,
|
||||
}), "user-1");
|
||||
const presentationOnly = await loadV9RectificationProfile(fakeAccounting({
|
||||
profile: {
|
||||
...completeProfile,
|
||||
birth_place_label: "邯郸(用户备注)",
|
||||
active_birth_time: "06:30",
|
||||
},
|
||||
}), "user-1");
|
||||
|
||||
assert.equal(first.baselineFingerprint, presentationOnly.baselineFingerprint);
|
||||
});
|
||||
|
||||
test("loadV9RectificationProfile normalizes PostgreSQL Date birth dates", async () => {
|
||||
const dateProfile = {
|
||||
...completeProfile,
|
||||
|
||||
@@ -22,8 +22,10 @@ const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on thi
|
||||
|
||||
const snapshot = {
|
||||
birth_date: "1997-08-08",
|
||||
birth_place_label: "河北省邯郸市武安市",
|
||||
latitude: 36.420487,
|
||||
longitude: 114.209936,
|
||||
timezone_id: "Asia/Shanghai",
|
||||
timezone_offset: 8,
|
||||
birth_time_source: "family_exact",
|
||||
reported_birth_time: "05:00",
|
||||
@@ -1085,7 +1087,7 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent"
|
||||
}
|
||||
});
|
||||
|
||||
test("v9 allows changing an accepted minute within the same live candidate result", { skip: skipWithoutDocker }, async () => {
|
||||
test("v9 ignores a historical active minute and allows reselection within the live result", { skip: skipWithoutDocker }, async () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
|
||||
try {
|
||||
@@ -1107,9 +1109,9 @@ test("v9 allows changing an accepted minute within the same live candidate resul
|
||||
update public.profiles
|
||||
set birth_date = '1997-08-08',
|
||||
reported_birth_time = '05:00',
|
||||
active_birth_time = null,
|
||||
birth_time = '05:00',
|
||||
birth_time_status = 'reported',
|
||||
active_birth_time = '04:30',
|
||||
birth_time = '04:30',
|
||||
birth_time_status = 'accepted',
|
||||
birth_time_source = 'family_exact',
|
||||
birth_time_period = null,
|
||||
uncertainty_before_minutes = 10,
|
||||
@@ -1140,6 +1142,27 @@ test("v9 allows changing an accepted minute within the same live candidate resul
|
||||
null,
|
||||
"service_role",
|
||||
);
|
||||
const inferenceState = {
|
||||
algorithm_version: "rectification-inference-v1",
|
||||
candidate_set_id: "04:50-05:10:05:07,05:08,05:09",
|
||||
revision: 0,
|
||||
phase: "discrimination",
|
||||
result_status: "completed_with_range",
|
||||
range_start: "04:50",
|
||||
range_end: "05:10",
|
||||
candidates: [
|
||||
{ id: "05:07", time: "05:07", cluster_range: ["05:07", "05:07"], prior_score: 34, posterior_score: 34, probability: 0.34, status: "active", rank: 2, strong_conflict_count: 0 },
|
||||
{ id: "05:08", time: "05:08", cluster_range: ["05:08", "05:08"], prior_score: 33, posterior_score: 40, probability: 0.4, status: "active", rank: 1, strong_conflict_count: 0 },
|
||||
{ id: "05:09", time: "05:09", cluster_range: ["05:09", "05:09"], prior_score: 33, posterior_score: 26, probability: 0.26, status: "active", rank: 3, strong_conflict_count: 0 },
|
||||
],
|
||||
events: [],
|
||||
probes: [],
|
||||
answered_probes: [],
|
||||
rounds: [],
|
||||
entropy: 1,
|
||||
representative_time: "05:08",
|
||||
credible_range: ["05:07", "05:09"],
|
||||
};
|
||||
const persisted = await service.rpc("persist_agentic_rectification_candidate_v2", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
@@ -1163,6 +1186,7 @@ test("v9 allows changing an accepted minute within the same live candidate resul
|
||||
representative_time: "05:08",
|
||||
overall_confidence: "low",
|
||||
margin_percent: 1,
|
||||
inference_state: inferenceState,
|
||||
},
|
||||
p_execution_ledger: [
|
||||
{ phase: "candidate.score", status: "completed", engine: "fixture-engine" },
|
||||
@@ -1183,6 +1207,252 @@ test("v9 allows changing an accepted minute within the same live candidate resul
|
||||
assert.match(switchedCandidateId, /^[0-9a-f-]{36}$/);
|
||||
assert.match(thirdCandidateId, /^[0-9a-f-]{36}$/);
|
||||
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_results
|
||||
set decision_receipt = decision_receipt - 'inference_state'
|
||||
where id = '${resultId}';
|
||||
`);
|
||||
const missingInference = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_result_id: resultId,
|
||||
p_candidate_id: firstCandidateId,
|
||||
p_request_id: "01010101-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
});
|
||||
assert.match(rpcError(missingInference.error), /agentic_rectification_candidate_state_inconsistent/);
|
||||
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_results
|
||||
set decision_receipt = jsonb_set(
|
||||
decision_receipt,
|
||||
'{inference_state}',
|
||||
('${JSON.stringify(inferenceState)}'::jsonb - 'revision'),
|
||||
true
|
||||
)
|
||||
where id = '${resultId}';
|
||||
`);
|
||||
const missingFallbackRevision = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_result_id: resultId,
|
||||
p_candidate_id: firstCandidateId,
|
||||
p_request_id: "01010102-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
});
|
||||
assert.match(
|
||||
rpcError(missingFallbackRevision.error),
|
||||
/agentic_rectification_candidate_state_inconsistent/,
|
||||
"a fallback receipt without revision must fail closed",
|
||||
);
|
||||
|
||||
const eliminatedInference = {
|
||||
...inferenceState,
|
||||
candidates: inferenceState.candidates.map((candidate) => (
|
||||
candidate.time === "05:09"
|
||||
? { ...candidate, status: "eliminated", probability: 0 }
|
||||
: candidate
|
||||
)),
|
||||
credible_range: ["05:07", "05:08"],
|
||||
};
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_results
|
||||
set decision_receipt = jsonb_set(decision_receipt, '{inference_state}', '${JSON.stringify(eliminatedInference)}'::jsonb, true)
|
||||
where id = '${resultId}';
|
||||
`);
|
||||
const eliminatedCandidate = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_result_id: resultId,
|
||||
p_candidate_id: thirdCandidateId,
|
||||
p_request_id: "02020202-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
});
|
||||
assert.match(rpcError(eliminatedCandidate.error), /agentic_rectification_candidate_state_inconsistent/);
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_results
|
||||
set decision_receipt = jsonb_set(decision_receipt, '{inference_state}', '${JSON.stringify(inferenceState)}'::jsonb, true)
|
||||
where id = '${resultId}';
|
||||
`);
|
||||
|
||||
const latestInferenceState = { ...inferenceState, revision: 1 };
|
||||
fixture.psql(`
|
||||
insert into public.agentic_rectification_inference_transitions (
|
||||
case_id, result_id, previous_revision, revision, candidate_set_id,
|
||||
probe_id, semantic_key, candidate_split_hash, answer_class, raw_answer,
|
||||
posterior_before, posterior_after, score_deltas, inference_state,
|
||||
decision_state_fingerprint, reason, idempotency_key, receipt_after
|
||||
) values (
|
||||
'${caseId}', '${resultId}', 0, 1, '${inferenceState.candidate_set_id}',
|
||||
'probe:fixture', 'fixture.latest-transition', '${"d".repeat(64)}', 'no', 'fixture',
|
||||
'{}'::jsonb, '{}'::jsonb, '{}'::jsonb, '${JSON.stringify(latestInferenceState)}'::jsonb,
|
||||
'${"e".repeat(64)}', 'choice', 'fixture-transition-revision-1', '{}'::jsonb
|
||||
);
|
||||
`);
|
||||
assert.equal(
|
||||
fixture.psql(`select decision_receipt #>> '{inference_state,revision}' from public.agentic_rectification_results where id = '${resultId}'`),
|
||||
"0",
|
||||
"the immutable engine receipt must remain at revision 0",
|
||||
);
|
||||
const composedRevision = () => fixture.psql(`
|
||||
select public.compose_agentic_rectification_decision_receipt(
|
||||
'${caseId}',
|
||||
'${resultId}',
|
||||
decision_receipt
|
||||
) #>> '{inference_state,revision}'
|
||||
from public.agentic_rectification_results
|
||||
where id = '${resultId}'
|
||||
`);
|
||||
const composedHasInference = () => fixture.psql(`
|
||||
select public.compose_agentic_rectification_decision_receipt(
|
||||
'${caseId}',
|
||||
'${resultId}',
|
||||
decision_receipt
|
||||
) ? 'inference_state'
|
||||
from public.agentic_rectification_results
|
||||
where id = '${resultId}'
|
||||
`);
|
||||
assert.equal(composedRevision(), "1", "a coherent latest transition must overlay the read receipt");
|
||||
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_inference_transitions
|
||||
set inference_state = jsonb_set(inference_state, '{revision}', '2'::jsonb, true)
|
||||
where case_id = '${caseId}' and revision = 1;
|
||||
`);
|
||||
assert.equal(composedHasInference(), "f", "a nested revision mismatch must hide stale read candidates");
|
||||
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_inference_transitions
|
||||
set inference_state = jsonb_set(
|
||||
'${JSON.stringify(latestInferenceState)}'::jsonb,
|
||||
'{candidate_set_id}',
|
||||
to_jsonb('wrong-set'::text),
|
||||
true
|
||||
)
|
||||
where case_id = '${caseId}' and revision = 1;
|
||||
`);
|
||||
assert.equal(composedHasInference(), "f", "a nested candidate-set mismatch must hide stale read candidates");
|
||||
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_inference_transitions
|
||||
set inference_state = '${JSON.stringify(latestInferenceState)}'::jsonb
|
||||
where case_id = '${caseId}' and revision = 1;
|
||||
`);
|
||||
assert.equal(composedRevision(), "1", "restoring a coherent transition must restore the overlay");
|
||||
|
||||
const malformedLatestInferenceStates = [
|
||||
{
|
||||
label: "status",
|
||||
state: {
|
||||
...latestInferenceState,
|
||||
candidates: latestInferenceState.candidates.map((candidate, index) => (
|
||||
index === 0 ? Object.fromEntries(Object.entries(candidate).filter(([key]) => key !== "status")) : candidate
|
||||
)),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "probability",
|
||||
state: {
|
||||
...latestInferenceState,
|
||||
candidates: latestInferenceState.candidates.map((candidate, index) => (
|
||||
index === 0 ? Object.fromEntries(Object.entries(candidate).filter(([key]) => key !== "probability")) : candidate
|
||||
)),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "posterior_score",
|
||||
state: {
|
||||
...latestInferenceState,
|
||||
candidates: latestInferenceState.candidates.map((candidate, index) => (
|
||||
index === 0 ? Object.fromEntries(Object.entries(candidate).filter(([key]) => key !== "posterior_score")) : candidate
|
||||
)),
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const [index, malformed] of malformedLatestInferenceStates.entries()) {
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_inference_transitions
|
||||
set inference_state = '${JSON.stringify(malformed.state)}'::jsonb
|
||||
where case_id = '${caseId}' and revision = 1;
|
||||
`);
|
||||
const malformedAccept = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_result_id: resultId,
|
||||
p_candidate_id: firstCandidateId,
|
||||
p_request_id: `0303030${index + 3}-aaaa-4aaa-8aaa-aaaaaaaaaaaa`,
|
||||
});
|
||||
assert.match(
|
||||
rpcError(malformedAccept.error),
|
||||
/agentic_rectification_candidate_state_inconsistent/,
|
||||
`missing ${malformed.label} must fail closed`,
|
||||
);
|
||||
}
|
||||
fixture.psql(`
|
||||
update public.agentic_rectification_inference_transitions
|
||||
set inference_state = '${JSON.stringify(latestInferenceState)}'::jsonb
|
||||
where case_id = '${caseId}' and revision = 1;
|
||||
`);
|
||||
|
||||
fixture.psql(`
|
||||
update public.profiles
|
||||
set declared_window_start = '04:45', declared_window_end = '05:15'
|
||||
where id = '${userId}';
|
||||
`);
|
||||
assert.equal(
|
||||
fixture.psql(`select status from public.agentic_rectification_cases where id = '${caseId}'`),
|
||||
"needs_rebaseline",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select invalidated_at is not null from public.agentic_rectification_results where id = '${resultId}'`),
|
||||
"t",
|
||||
);
|
||||
fixture.psql(`
|
||||
set session_replication_role = replica;
|
||||
update public.profiles
|
||||
set declared_window_start = null, declared_window_end = null
|
||||
where id = '${userId}';
|
||||
update public.agentic_rectification_cases
|
||||
set status = 'candidate_ready'
|
||||
where id = '${caseId}';
|
||||
update public.agentic_rectification_results
|
||||
set invalidated_at = null
|
||||
where id = '${resultId}';
|
||||
set session_replication_role = origin;
|
||||
|
||||
update public.profiles
|
||||
set birth_place_label = '邯郸(用户备注)', active_birth_time = '04:31'
|
||||
where id = '${userId}';
|
||||
`);
|
||||
assert.equal(
|
||||
fixture.psql(`select status from public.agentic_rectification_cases where id = '${caseId}'`),
|
||||
"candidate_ready",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select invalidated_at is null from public.agentic_rectification_results where id = '${resultId}'`),
|
||||
"t",
|
||||
);
|
||||
|
||||
fixture.psql(`
|
||||
set session_replication_role = replica;
|
||||
update public.profiles set timezone_id = 'Asia/Taipei' where id = '${userId}';
|
||||
set session_replication_role = origin;
|
||||
`);
|
||||
const staleTimezoneFirstAccept = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_result_id: resultId,
|
||||
p_candidate_id: firstCandidateId,
|
||||
p_request_id: "00000000-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
});
|
||||
assert.match(rpcError(staleTimezoneFirstAccept.error), /agentic_rectification_candidate_profile_changed/);
|
||||
assert.equal(
|
||||
fixture.psql(`select selected_candidate_id is null from public.agentic_rectification_results where id = '${resultId}'`),
|
||||
"t",
|
||||
);
|
||||
fixture.psql(`
|
||||
set session_replication_role = replica;
|
||||
update public.profiles set timezone_id = 'Asia/Shanghai' where id = '${userId}';
|
||||
set session_replication_role = origin;
|
||||
`);
|
||||
|
||||
const firstAccept = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
@@ -1192,6 +1462,41 @@ test("v9 allows changing an accepted minute within the same live candidate resul
|
||||
});
|
||||
assert.equal(firstAccept.error, null, rpcError(firstAccept.error));
|
||||
|
||||
fixture.psql(`
|
||||
set session_replication_role = replica;
|
||||
update public.profiles
|
||||
set birth_date = '1997-08-09',
|
||||
birth_place_label = '台北市',
|
||||
latitude = 25.033,
|
||||
longitude = 121.5654,
|
||||
timezone_id = 'Asia/Taipei'
|
||||
where id = '${userId}';
|
||||
set session_replication_role = origin;
|
||||
`);
|
||||
const staleReselection = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
|
||||
p_user_id: userId,
|
||||
p_case_id: caseId,
|
||||
p_result_id: resultId,
|
||||
p_candidate_id: switchedCandidateId,
|
||||
p_request_id: "12121212-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
});
|
||||
assert.match(rpcError(staleReselection.error), /agentic_rectification_candidate_profile_changed/);
|
||||
assert.equal(
|
||||
fixture.psql(`select selected_candidate_id from public.agentic_rectification_results where id = '${resultId}'`),
|
||||
firstCandidateId,
|
||||
);
|
||||
fixture.psql(`
|
||||
set session_replication_role = replica;
|
||||
update public.profiles
|
||||
set birth_date = '1997-08-08',
|
||||
birth_place_label = '河北省邯郸市武安市',
|
||||
latitude = 36.420487,
|
||||
longitude = 114.209936,
|
||||
timezone_id = 'Asia/Shanghai'
|
||||
where id = '${userId}';
|
||||
set session_replication_role = origin;
|
||||
`);
|
||||
|
||||
const switchRequestId = "22222222-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const switched = await service.rpc("accept_agentic_rectification_candidate_for_case_v2", {
|
||||
p_user_id: userId,
|
||||
|
||||
@@ -1296,3 +1296,116 @@ test("turn origin migration stays out of the identity foundation", () => {
|
||||
assert.match(turnOriginMigration, /message_origin/);
|
||||
assert.match(turnOriginMigration, /suggestion_click/);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 20260827010000_rectification_candidate_profile_freshness.sql
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const candidateProfileFreshnessMigration = readFileSync(
|
||||
new URL(
|
||||
"../supabase/migrations/20260827010000_rectification_candidate_profile_freshness.sql",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const candidateProfileFreshnessMigrationCopy = fileURLToPath(
|
||||
new URL(
|
||||
"../db/migrations/20260827010000_rectification_candidate_profile_freshness.sql",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
test("candidate profile freshness is a unique forward-only business migration", () => {
|
||||
assert.ok(
|
||||
"20260827010000_rectification_candidate_profile_freshness.sql" >
|
||||
"20260826030000_rectification_migration_ledger_health.sql",
|
||||
);
|
||||
assert.equal(candidateProfileFreshnessMigration.match(/^begin;$/gm)?.length, 1);
|
||||
assert.equal(candidateProfileFreshnessMigration.match(/^commit;$/gm)?.length, 1);
|
||||
assert.equal(
|
||||
existsSync(candidateProfileFreshnessMigrationCopy),
|
||||
false,
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
});
|
||||
|
||||
test("candidate profile freshness replaces the V2 accept RPC without widening its ownership", () => {
|
||||
assert.equal(
|
||||
candidateProfileFreshnessMigration.match(
|
||||
/create or replace function public\.accept_agentic_rectification_candidate_for_case_v2\(/g,
|
||||
)?.length,
|
||||
1,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
candidateProfileFreshnessMigration,
|
||||
/create or replace function public\.confirm_agentic_rectification_candidate_for_case_v2\(/,
|
||||
);
|
||||
assert.match(
|
||||
candidateProfileFreshnessMigration,
|
||||
/revoke all on function public\.accept_agentic_rectification_candidate_for_case_v2\(uuid, uuid, uuid, uuid, uuid\)[\s\S]*from public, anon, authenticated/,
|
||||
);
|
||||
assert.match(
|
||||
candidateProfileFreshnessMigration,
|
||||
/grant execute on function public\.accept_agentic_rectification_candidate_for_case_v2\(uuid, uuid, uuid, uuid, uuid\)[\s\S]*to service_role/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("candidate profile freshness guards first acceptance and reselection with the full engine input baseline", () => {
|
||||
const acceptFunction = candidateProfileFreshnessMigration.slice(
|
||||
candidateProfileFreshnessMigration.indexOf(
|
||||
"create or replace function public.accept_agentic_rectification_candidate_for_case_v2(",
|
||||
),
|
||||
candidateProfileFreshnessMigration.indexOf("commit;"),
|
||||
);
|
||||
const replay = acceptFunction.indexOf("select * into v_existing_decision");
|
||||
const guard = acceptFunction.indexOf("v_snapshot := v_case.baseline_birth_snapshot;");
|
||||
const selection = acceptFunction.indexOf("if v_result.selected_candidate_id is not null then");
|
||||
const freshnessChecks = acceptFunction.slice(guard, selection);
|
||||
|
||||
assert.ok(replay >= 0 && replay < guard, "request-id replay must remain ahead of freshness checks");
|
||||
assert.ok(guard >= 0 && guard < selection, "one guard must protect initial acceptance and reselection");
|
||||
assert.match(freshnessChecks, /v_profile\.birth_date is distinct from/);
|
||||
assert.match(freshnessChecks, /v_profile\.reported_birth_time is distinct from/);
|
||||
assert.match(freshnessChecks, /v_profile\.declared_window_start is distinct from/);
|
||||
assert.match(freshnessChecks, /v_profile\.declared_window_end is distinct from/);
|
||||
assert.match(freshnessChecks, /v_profile\.latitude is distinct from/);
|
||||
assert.match(freshnessChecks, /v_profile\.longitude is distinct from/);
|
||||
assert.match(freshnessChecks, /v_profile\.timezone_id is distinct from v_snapshot ->> 'timezone_id'/);
|
||||
assert.match(freshnessChecks, /v_profile\.timezone_offset is distinct from/);
|
||||
assert.doesNotMatch(freshnessChecks, /v_profile\.active_birth_time is distinct from \(v_snapshot/);
|
||||
assert.doesNotMatch(freshnessChecks, /v_profile\.birth_place_label is distinct from v_snapshot/);
|
||||
});
|
||||
|
||||
test("candidate freshness trigger uses only calculation inputs", () => {
|
||||
const rebaseline = candidateProfileFreshnessMigration.slice(
|
||||
candidateProfileFreshnessMigration.indexOf(
|
||||
"create or replace function public.agentic_rectification_profiles_rebaseline_guard()",
|
||||
),
|
||||
candidateProfileFreshnessMigration.indexOf(
|
||||
"create or replace function public.invalidate_agentic_rectification_results_on_profile_change()",
|
||||
),
|
||||
);
|
||||
const invalidation = candidateProfileFreshnessMigration.slice(
|
||||
candidateProfileFreshnessMigration.indexOf(
|
||||
"create or replace function public.invalidate_agentic_rectification_results_on_profile_change()",
|
||||
),
|
||||
candidateProfileFreshnessMigration.indexOf("commit;"),
|
||||
);
|
||||
|
||||
for (const source of [rebaseline, invalidation]) {
|
||||
assert.match(source, /old\.declared_window_start is distinct from new\.declared_window_start/);
|
||||
assert.match(source, /old\.declared_window_end is distinct from new\.declared_window_end/);
|
||||
assert.doesNotMatch(source, /old\.birth_place_label is distinct from new\.birth_place_label/);
|
||||
assert.doesNotMatch(source, /old\.active_birth_time is distinct from new\.active_birth_time/);
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
rebaseline,
|
||||
/after update of[^;]*birth_place_label|after update of[^;]*active_birth_time/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
invalidation,
|
||||
/after update of[^;]*birth_place_label|after update of[^;]*active_birth_time/,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user