diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 342b94a5..457e1651 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -9934,8 +9934,8 @@ - 用户现象:报告宽度跟区间两端对得上,引擎「不可分宽度」却按各簇代表分钟算,少算两端延伸;极端时可把 ≤5 分钟的确认门误开。 - 触发条件:各簇 `cluster_start` / `cluster_end` 超出代表分钟。 - 根因:宽度公式用代表分钟的 `max-min+1`,不是簇覆盖。 -- 修复:改为 `max(cluster_end)-min(cluster_start)+1`;缺簇字段时回退到 `time`。`tied_minute_count` 不动。 -- 验证:`tests/test_rectification_confirmation_and.py`、`frontend/tests/rectification-delivery-report-facts.test.ts`、`rectification-confirmation-gate.test.ts`。 +- 修复:改为 `max(cluster_end)-min(cluster_start)+1`;缺簇字段时回退到 `time`。`tied_minute_count` 不动。决策层与客户端解析把 `clusterStart` / `clusterEnd` 传进确认门。 +- 验证:`tests/test_rectification_confirmation_and.py`、`frontend/tests/rectification-delivery-report-facts.test.ts`、`rectification-confirmation-gate.test.ts`、`rectification-candidate-result.test.ts`。 - 防复发:两簇 04:50–04:55 与 04:56–05:02 必须得到 13,不得算成 7。确认门不得因代表分钟看起来相邻而打开。 - 相关记录:BUG-593 - 复发自:无 diff --git a/docs/tasks/PROGRESS-rectification-latent-audit-20260910.md b/docs/tasks/PROGRESS-rectification-latent-audit-20260910.md index 9393b3e4..24f29005 100644 --- a/docs/tasks/PROGRESS-rectification-latent-audit-20260910.md +++ b/docs/tasks/PROGRESS-rectification-latent-audit-20260910.md @@ -19,7 +19,7 @@ Python 按 **argmax 映射到所在簇** 比较,不改成代表分钟列表。 - **BUG-637**:账本派生的精确 `domain.year` 键只拦存在性探针。`known_event_quality` / `event_quality` 用 `askedKeysForSameYearDedup` 去掉这些键后再做同年去重;已答质量键仍丢。`remainingConflictProbes` 本来就跳过质量题,未改。不改 `SCORE_DELTA`、四选项、确认门。 - **BUG-638**:`dasha_agreement(..., clusters=)` 与 TS `dashaAgreementAmongActive(..., clusters)` 按簇比较。`decision_policy` 把 `candidate_decisions` 传入 `build_refinement_packet`。引擎原值仍是 `dasha_agreement_pre_inference`。BUG-593 记录已补正。 -- **BUG-639**:`indistinguishable_width_minutes` / `indistinguishableWidthMinutes` 用 `max(cluster_end)-min(cluster_start)+1`;缺字段回退到 `time`。`tied_minute_count` 不动。 +- **BUG-639**:`indistinguishable_width_minutes` / `indistinguishableWidthMinutes` 用 `max(cluster_end)-min(cluster_start)+1`;缺字段回退到 `time`。`tied_minute_count` 不动。`decideFromDossier` 与 `parseRectificationCandidateResult` 把 `clusterStart` / `clusterEnd` 传进确认门,避免刷新路径只按代表分钟少算。 - **BUG-640**:推断代表分钟 ≠ 引擎代表分钟时,公开 `house_table` / `natal_recast` 跟推断分钟;引擎原值改名 `*_pre_inference`。Agent 可见投影剥掉这三个字段。不改引擎。 - 记录:BUG-389/593/637~640、CHANGELOG、本文件。Skill 版本不变(`10.0.21`)。 diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 2f558255..0ab85dc7 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -96,6 +96,10 @@ export type DecisionDossier = Readonly<{ relativeSupport?: number; posterior_score?: number; tiedMinuteCount?: number; + clusterStart?: string; + clusterEnd?: string; + cluster_start?: string; + cluster_end?: string; }>[]; representativeTime?: string | null; evidenceLedgerFingerprint?: string | null; @@ -667,6 +671,12 @@ export function decideFromDossier( time: candidate.time, rank: candidate.rank ?? Number.MAX_SAFE_INTEGER, tiedMinuteCount: candidate.tiedMinuteCount ?? Number.MAX_SAFE_INTEGER, + ...(candidate.clusterStart ?? candidate.cluster_start + ? { clusterStart: candidate.clusterStart ?? candidate.cluster_start } + : {}), + ...(candidate.clusterEnd ?? candidate.cluster_end + ? { clusterEnd: candidate.clusterEnd ?? candidate.cluster_end } + : {}), })), decisionReceipt: latest?.decisionReceipt ?? null, }); diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index 79fb8bba..dd455153 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -115,7 +115,6 @@ import { isEventQualityProbe, sameYearProbeAsked, SEMANTIC_YEAR_KEY, - EXISTENCE_STYLE_OPTIONS, type DroppedProbe, type ProbeStyleOption, } from "./probe-question-contract.ts"; diff --git a/frontend/src/lib/rectification-candidate-result.ts b/frontend/src/lib/rectification-candidate-result.ts index 6b8fe076..b09556ac 100644 --- a/frontend/src/lib/rectification-candidate-result.ts +++ b/frontend/src/lib/rectification-candidate-result.ts @@ -44,6 +44,8 @@ export type RectificationCandidate = Readonly<{ time: string; relativeSupport: number; tiedMinuteCount: number; + clusterStart?: string; + clusterEnd?: string; }>; export type RectificationHouseRow = Readonly<{ @@ -329,7 +331,17 @@ export function parseRectificationCandidateResult(value: unknown): Rectification || relativeSupport === null || !Number.isInteger(relativeSupport) || relativeSupport < 0 || relativeSupport > 100 || tiedMinuteCount === null || !Number.isInteger(tiedMinuteCount) || tiedMinuteCount < 1 ) return null; - candidates.push({ candidateId, rank, time: candidateTime, relativeSupport, tiedMinuteCount }); + const clusterStart = time(candidate.clusterStart ?? candidate.cluster_start); + const clusterEnd = time(candidate.clusterEnd ?? candidate.cluster_end); + candidates.push({ + candidateId, + rank, + time: candidateTime, + relativeSupport, + tiedMinuteCount, + ...(clusterStart ? { clusterStart } : {}), + ...(clusterEnd ? { clusterEnd } : {}), + }); } const receipt = receiptFromSnapshot(snapshot); diff --git a/frontend/tests/rectification-candidate-result.test.ts b/frontend/tests/rectification-candidate-result.test.ts index 1ee5b16f..41b13270 100644 --- a/frontend/tests/rectification-candidate-result.test.ts +++ b/frontend/tests/rectification-candidate-result.test.ts @@ -343,4 +343,38 @@ test("inference marks are empty when inference_state is absent", () => { assert.deepEqual(result.inferenceMarks, []); }); +test("parsed candidates keep cluster span so confirmation width is not undercounted", () => { + const result = parseRectificationCandidateResult({ + ...camelCaseSnapshot, + candidates: [ + { + candidateId: CANDIDATE_ID, + rank: 1, + time: "04:53", + relativeSupport: 40, + tiedMinuteCount: 1, + clusterStart: "04:50", + clusterEnd: "04:55", + }, + { + candidateId: SECOND_CANDIDATE_ID, + rank: 2, + time: "04:59", + relativeSupport: 39, + tiedMinuteCount: 1, + clusterStart: "04:56", + clusterEnd: "05:02", + }, + ], + }); + assert.ok(result); + assert.equal(result.candidates[0]?.clusterStart, "04:50"); + assert.equal(result.candidates[0]?.clusterEnd, "04:55"); + const adjacent = result.confirmationGate.blockers.find( + (row) => row.id === "adjacent_minutes_indistinguishable", + ); + assert.equal(adjacent?.indistinguishable_width_minutes, 13); + assert.equal(result.confirmationGate.confirmation_allowed, false); +}); + diff --git a/frontend/tests/rectification-confirmation-gate.test.ts b/frontend/tests/rectification-confirmation-gate.test.ts index 259ed645..abd754d4 100644 --- a/frontend/tests/rectification-confirmation-gate.test.ts +++ b/frontend/tests/rectification-confirmation-gate.test.ts @@ -414,6 +414,15 @@ test("holdout not_ready forbids unique-minute copy and still blocks confirm", as assert.equal(safeToolErrorCode(new RectificationToolServiceError("confirmation_blocked")), "confirmation_blocked"); }); +test("decideFromDossier passes cluster span into the confirmation gate", () => { + const src = readFileSync( + new URL("../src/lib/rectification-agentic/v9/decision-from-dossier.ts", import.meta.url), + "utf8", + ); + assert.match(src, /clusterStart: candidate\.clusterStart \?\? candidate\.cluster_start/); + assert.match(src, /clusterEnd: candidate\.clusterEnd \?\? candidate\.cluster_end/); +}); + test("confirmation width uses cluster span so representative minutes cannot open the gate", () => { const candidates = [ { time: "04:53", rank: 1, tiedMinuteCount: 1, clusterStart: "04:50", clusterEnd: "04:55" },