diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 7aeee02b..6d552e5a 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6413,6 +6413,22 @@ - 复发自:BUG-403(动态四选项合同未贯穿丢题原因) - 修复版本:待发布 +## BUG-423 | 写盘 still-valid 区间与读盘全体跨度不一致,区分永远收不了口 + +- 状态:resolved +- 首次发现:2026-08-28 +- 最近更新:2026-08-28 +- 影响面:`authoritativeCandidateProjection`、GET/工具候选投影、`decideFromDossier` 的 `candidateScores` +- 用户现象:区分环节答再多题也走不到 `ask_holdout_validation` 或 `ready_to_adopt`,最后落到 `offer_provisional_range` 且 `representative_time` 为 null。第一次打分、两候选 58/42 时投影就已经是空列表。 +- 触发条件:receipt 由 `buildInferenceState` 写入 `unionStillValidRange`(落后峰值 ≥ 8 的候选不进区间),读盘却要求 `credible_range` 等于全体 active 跨度。lead ≥ 8 时两者必然分叉。 +- 根因:写盘口径是 still-valid 并集,读盘校验口径是全体 active 跨度,阈值同为 `MIN_SEPARATION_LEAD = 8`,构成闭环死锁。`consistent = false` 清空 `scores`,`evaluateCandidateSeparation` 判不出已拉开。既有 fixture 把分差写成 < 8 或手写宽 range 冒充生产者输出,所以测试红不了。 +- 修复:`receiptRangeMatches` 改与 `unionStillValidRange(inference.candidates)` 比对,投影对外的 `credibleRange` 返回该 still-valid 并集。每个 active `cluster_range` 落在全体跨度内的包含性校验保留。没有 `inference_state`、坏 candidate set、或窗口外/颠倒的 range 仍 fail-closed。不改阈值、不改确认门、不改 Skill。 +- 验证:`rectification-credible-range-projection` 用 `buildInferenceState` / `buildCaseInferenceState` 锁定 58/42、50/30/20、单候选、无 inference;58/42 决策层 `separation.sufficient` 且 `ask_holdout_validation` 或 `ready_to_adopt`,`canConfirmExactMinute === false`。`rectification-eight-method` 把「窄 range 当损坏」改为窗口外 `["04:00","04:10"]`。`rectification-decision-authority` 的损坏 range 同样改为窗口外或首尾颠倒。 +- 防复发:不得让写盘与读盘对 `credible_range` 使用不同口径。不得用手写 `credible_range` 字面量的 fixture 覆盖这条生产者路径。不得把 `consistent = false` 改成 fail-open。不得用改 `MIN_SEPARATION_LEAD` 绕过死锁。 +- 相关记录:BUG-403、BUG-421 +- 复发自:BUG-403(候选卡、代表时间、可信区间必须共享 inference 投影,但读写口径未钉成同一函数) +- 修复版本:待发布 + ## BUG-410 | 训练已齐仍因家人/职业方法层停在采集,Agent 只确认后截断 - 状态:resolved diff --git a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts index f11e3141..bd222bd3 100644 --- a/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts +++ b/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts @@ -12,7 +12,7 @@ import { probeFromEngine } from "../core/probes-from-engine.ts"; import { selectHighestGainProbe } from "../core/select-probe.ts"; import { askedEventProbeKeysFromLedgerEvidence } from "../core/candidate-contrast-packet.ts"; import { rankActive } from "../core/convergence-evaluator.ts"; -import { rangeFromTimes } from "../core/credible-range.ts"; +import { rangeFromTimes, unionStillValidRange } from "../core/credible-range.ts"; import { previousInferenceFromReceipt } from "../core/compose-receipt.ts"; import type { AnswerClass, ConflictProbe, InferenceState } from "../core/types.ts"; import { @@ -135,22 +135,23 @@ export function authoritativeCandidateProjection }); const representativeTime = active[0]?.time ?? null; const activePoints = active.flatMap((item) => [item.cluster_range[0], item.time, item.cluster_range[1]]); - const authoritativeRange = rangeFromTimes(activePoints); + const activeSpan = rangeFromTimes(activePoints); + const stillValidRange = unionStillValidRange(inference.candidates); const receiptRange = inference.credible_range; - const activeRangesValid = Boolean(authoritativeRange) && active.every((item) => { + const activeRangesValid = Boolean(activeSpan) && active.every((item) => { const clusterRange = rangeFromTimes(item.cluster_range); return clusterRange?.[0] === item.cluster_range[0] && clusterRange[1] === item.cluster_range[1] && rangeFromTimes([item.time])?.[0] === item.time && item.time >= clusterRange[0] && item.time <= clusterRange[1] - && item.cluster_range[0] >= authoritativeRange![0] - && item.cluster_range[1] <= authoritativeRange![1]; + && item.cluster_range[0] >= activeSpan![0] + && item.cluster_range[1] <= activeSpan![1]; }); - const receiptRangeMatches = Boolean(authoritativeRange && receiptRange) + const receiptRangeMatches = Boolean(stillValidRange && receiptRange) && receiptRange![0] <= receiptRange![1] - && receiptRange![0] === authoritativeRange![0] - && receiptRange![1] === authoritativeRange![1]; + && receiptRange![0] === stillValidRange![0] + && receiptRange![1] === stillValidRange![1]; const consistent = active.length > 0 && completeCandidateSet && candidates.length === active.length @@ -165,7 +166,7 @@ export function authoritativeCandidateProjection ? active.map((item) => ({ id: item.id, time: item.time, score: item.posterior_score })) : [], representativeTime: consistent ? representativeTime : null, - credibleRange: consistent ? authoritativeRange : null, + credibleRange: consistent ? stillValidRange : null, }; } diff --git a/frontend/tests/rectification-credible-range-projection.test.ts b/frontend/tests/rectification-credible-range-projection.test.ts new file mode 100644 index 00000000..7ab4b8c5 --- /dev/null +++ b/frontend/tests/rectification-credible-range-projection.test.ts @@ -0,0 +1,219 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildInferenceState, type EngineEventInput } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { unionStillValidRange } from "../src/lib/rectification-agentic/core/credible-range.ts"; +import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts"; +import { + authoritativeCandidateProjection, + buildCaseInferenceState, +} from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; +import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; + +const HOLDOUT_EVENTS: readonly EngineEventInput[] = [ + { id: "e-edu", domain: "education", year: 2016, precision: "year" }, + { id: "e-career", domain: "career", year: 2018, precision: "year" }, + { id: "e-rel", domain: "relationship", year: 2021, precision: "year" }, + { id: "e-fam", domain: "family", year: 2023, precision: "year" }, +]; + +const COVERAGE_EVIDENCE = [ + { + id: "e-edu", + status: "confirmed", + domain: "education", + datePrecision: "year", + occurredFrom: "2016-01-01", + occurredTo: null, + eventKind: "education_milestone", + }, + { + id: "e-career", + status: "confirmed", + domain: "career", + datePrecision: "year", + occurredFrom: "2018-01-01", + occurredTo: null, + eventKind: "career_entry", + }, + { + id: "e-rel", + status: "confirmed", + domain: "relationship", + datePrecision: "year", + occurredFrom: "2021-01-01", + occurredTo: null, + eventKind: "relationship_start", + }, + { + id: "e-fam", + status: "confirmed", + domain: "family", + datePrecision: "year", + occurredFrom: "2023-01-01", + occurredTo: null, + eventKind: "family_event", + }, + { + id: "e-occ", + status: "confirmed", + domain: "occupation", + datePrecision: "unknown", + occurredFrom: null, + occurredTo: null, + eventKind: "occupation_note", + }, +] as const; + +function producedSnapshot( + rows: readonly Readonly<{ time: string; relativeSupport: number }>[], + events: readonly EngineEventInput[] = [], +) { + const times = rows.map((item) => item.time).sort(); + const state = buildInferenceState({ + range_start: times[0]!, + range_end: times[times.length - 1]!, + candidates: rows.map((item) => ({ + id: item.time, + time: item.time, + relative_support: item.relativeSupport, + })), + events, + probes: [], + }); + const persisted = state.candidates.map((item) => ({ + candidateId: item.id, + time: item.time, + rank: item.rank, + relativeSupport: Math.round(item.posterior_score), + })); + return { + state, + latest: { + resultId: "55555555-5555-4555-8555-555555555555", + candidates: persisted, + representativeTime: state.representative_time, + evidenceLedgerFingerprint: evidenceLedgerFingerprint(COVERAGE_EVIDENCE as never), + decisionReceipt: { inference_state: state }, + }, + }; +} + +function dossierFor(latest: ReturnType["latest"]) { + return { + evidence: COVERAGE_EVIDENCE, + conversationSummary: { activeFocus: null, declinedSkippedTopics: [] }, + latestResult: latest, + case: { acceptedTime: null }, + }; +} + +test("producer 58/42 lead keeps both candidates and can close without opening the minute gate", () => { + const { state, latest } = producedSnapshot([ + { time: "05:02", relativeSupport: 58 }, + { time: "04:55", relativeSupport: 42 }, + ], HOLDOUT_EVENTS); + assert.deepEqual(state.credible_range, unionStillValidRange(state.candidates)); + assert.notDeepEqual(state.credible_range, ["04:55", "05:02"]); + + const projection = authoritativeCandidateProjection(latest); + assert.equal(projection.consistent, true); + assert.equal(projection.candidates.length, 2); + assert.deepEqual(projection.credibleRange, state.credible_range); + + const decision = decideFromDossier(dossierFor(latest)); + assert.equal(decision.separation.sufficient, true); + assert.equal(decision.nextAction, "ask_holdout_validation"); + assert.equal(decision.canConfirmExactMinute, false); + assert.notEqual(decision.sessionOutcome, "exact_minute_confirmed"); +}); + +test("producer 58/42 with no holdout is ready_to_adopt, not a unique minute", () => { + const { latest } = producedSnapshot([ + { time: "05:02", relativeSupport: 58 }, + { time: "04:55", relativeSupport: 42 }, + ]); + const decision = decideFromDossier(dossierFor(latest)); + assert.equal(authoritativeCandidateProjection(latest).consistent, true); + assert.equal(decision.separation.sufficient, true); + assert.equal(decision.nextAction, "ready_to_adopt"); + assert.equal(decision.canConfirmExactMinute, false); + assert.notEqual(decision.sessionOutcome, "exact_minute_confirmed"); +}); + +test("producer 50/30/20 lead keeps all three candidates and still-valid range", () => { + const { state, latest } = producedSnapshot([ + { time: "05:00", relativeSupport: 50 }, + { time: "05:20", relativeSupport: 30 }, + { time: "05:40", relativeSupport: 20 }, + ]); + const projection = authoritativeCandidateProjection(latest); + assert.equal(projection.consistent, true); + assert.equal(projection.candidates.length, 3); + assert.deepEqual(projection.credibleRange, unionStillValidRange(state.candidates)); + assert.deepEqual(projection.credibleRange, state.credible_range); +}); + +test("producer sole candidate stays consistent", () => { + const { state, latest } = producedSnapshot([ + { time: "05:02", relativeSupport: 100 }, + ]); + const projection = authoritativeCandidateProjection(latest); + assert.equal(projection.consistent, true); + assert.equal(projection.candidates.length, 1); + assert.deepEqual(projection.credibleRange, unionStillValidRange(state.candidates)); +}); + +test("missing inference_state still fail-closes to an empty projection", () => { + const projection = authoritativeCandidateProjection({ + candidates: [ + { candidateId: "05:02", time: "05:02", relativeSupport: 58 }, + { candidateId: "04:55", time: "04:55", relativeSupport: 42 }, + ], + decisionReceipt: {}, + }); + assert.equal(projection.consistent, false); + assert.deepEqual(projection.candidates, []); + assert.deepEqual(projection.scores, []); + assert.equal(projection.representativeTime, null); + assert.equal(projection.credibleRange, null); +}); + +test("a corrupted receipt range outside the window still fail-closes", () => { + const { state, latest } = producedSnapshot([ + { time: "05:02", relativeSupport: 58 }, + { time: "04:55", relativeSupport: 42 }, + ]); + const broken = { + ...latest, + decisionReceipt: { + inference_state: { ...state, credible_range: ["04:00", "04:10"] }, + }, + }; + const projection = authoritativeCandidateProjection(broken); + assert.equal(projection.consistent, false); + assert.deepEqual(projection.candidates, []); + assert.equal(projection.credibleRange, null); +}); + +test("buildCaseInferenceState receipt is the producer of the projected range", () => { + const state = buildCaseInferenceState({ + range: { start_time: "04:55", end_time: "05:02" }, + candidates: [ + { candidateId: "05:02", time: "05:02", relativeSupport: 58 }, + { candidateId: "04:55", time: "04:55", relativeSupport: 42 }, + ], + evidence: [], + probes: [], + }); + const projection = authoritativeCandidateProjection({ + candidates: state.candidates.map((item) => ({ + candidateId: item.id, + time: item.time, + relativeSupport: Math.round(item.posterior_score), + })), + decisionReceipt: { inference_state: state }, + }); + assert.equal(projection.consistent, true); + assert.deepEqual(projection.credibleRange, unionStillValidRange(state.candidates)); +}); diff --git a/frontend/tests/rectification-decision-authority.test.ts b/frontend/tests/rectification-decision-authority.test.ts index 2eecd3fc..cabdbc70 100644 --- a/frontend/tests/rectification-decision-authority.test.ts +++ b/frontend/tests/rectification-decision-authority.test.ts @@ -633,7 +633,7 @@ test("public candidate cards follow the inference ranking and hide an inconsiste 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) { + for (const credibleRange of [["04:00", "04:10"], ["05:07", "05:00"]] as const) { const rangeMismatch = overlayPublicDecision({ ...base, decisionReceipt: { diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index d4fd38b2..a46dca96 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -14,6 +14,7 @@ import { } from "../src/lib/rectification-agentic/v9/varga-observations.ts"; import { WINDOW_SCAN_DISPLAY_LAYER_ORDER } from "../src/lib/rectification-agentic/v9/refinement-packet.ts"; import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; +import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; import { decideRectification } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { readVedastroMinuteSensitiveStatus } from "../src/lib/rectification-agentic/v9/confirmation-gate.ts"; import { authoritativeCandidateProjection } from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; @@ -58,6 +59,23 @@ function collectDecision(candidates: readonly { time: string; relativeSupport?: }); } +function producedInferenceState( + candidates: readonly Readonly<{ id: string; time: string; relative_support: number }>[], +) { + const times = candidates.map((item) => item.time).sort(); + return buildInferenceState({ + range_start: times[0]!, + range_end: times[times.length - 1]!, + candidates: candidates.map((item) => ({ + id: item.id, + time: item.time, + relative_support: item.relative_support, + })), + events: [], + probes: [], + }); +} + const DYNAMIC_STYLE_OPTIONS = [ { label: "明确发生且时间吻合", answer_class: "yes" as const }, { label: "发生过但程度较弱", answer_class: "weak_yes" as const }, @@ -1020,26 +1038,10 @@ test("cached candidates retry a failed VedAstro validation without recomputing r representativeTime: "05:02", evidenceLedgerFingerprint: evidenceFingerprint, decisionReceipt: { - inference_state: { - algorithm_version: "rectification-inference-v1", - candidate_set_id: "04:55-05:02:04:55,05:02", - revision: 1, - phase: "discrimination", - result_status: "credible_range", - range_start: "04:55", - range_end: "05:02", - candidates: [ - { id: CANDIDATE_ID, time: "05:02", cluster_range: ["05:02", "05:02"], prior_score: 58, posterior_score: 58, probability: 0.58, status: "active", rank: 1, strong_conflict_count: 0 }, - { id: SECOND_CANDIDATE_ID, time: "04:55", cluster_range: ["04:55", "04:55"], 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: "05:02", - credible_range: ["04:55", "05:02"], - }, + inference_state: producedInferenceState([ + { id: CANDIDATE_ID, time: "05:02", relative_support: 58 }, + { id: SECOND_CANDIDATE_ID, time: "04:55", relative_support: 42 }, + ]), gates: { exact_confirmation: { external_validation_status: "failed", @@ -1361,27 +1363,15 @@ test("public tool surface stays at 14 and new cases bind 10.0.13", () => { assert.match(tools, /current_probe: null/); }); -test("Mastra hides active candidates when the receipt range excludes one of them", () => { +test("Mastra hides active candidates when the receipt range is corrupted", () => { 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 inferenceState = producedInferenceState([ + { id: CANDIDATE_ID, time: "05:00", relative_support: 20 }, + { id: SECOND_CANDIDATE_ID, time: "05:07", relative_support: 15 }, + ]); const latest = { resultId: RESULT_ID, candidates, @@ -1408,7 +1398,7 @@ test("Mastra hides active candidates when the receipt range excludes one of them const invalid = latestResultToolProjection({ ...latest, decisionReceipt: { - inference_state: { ...inferenceState, credible_range: ["05:00", "05:00"] }, + inference_state: { ...inferenceState, credible_range: ["04:00", "04:10"] }, }, }, session); assert.deepEqual(invalid.candidates, []); @@ -2810,26 +2800,10 @@ test("paused case with selection_allowed may offer the escape hatch", async () = { 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"], - }, + inference_state: producedInferenceState([ + { id: CANDIDATE_ID, time: "04:48", relative_support: 58 }, + { id: SECOND_CANDIDATE_ID, time: "04:49", relative_support: 42 }, + ]), }, }), }),