From 8e901125a96d1faf82e6bd546d8e6c891948894c Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 1 Oct 2026 09:51:55 +0800 Subject: [PATCH] fix(rectification): judge chart reliability against the saved minute, not the result's head (BUG-1138 review) A chart is reliable only when its tier is not blocked/indistinct and the saved minute (provenance window_offset_minutes) sits in that chart's head segment, or the chart never changes in the window; a missing offset is not reliable. The saved minute does not move with later results, and D7 falls back to the D1 head midpoint when heads do not intersect, so the tier alone could name a sign the saved minute does not have. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8 --- docs/BUG_HISTORY.md | 1 + ...ctification-chart-tier-caveats-20261001.md | 8 +++++ .../lib/rectification-adopted-chart-tiers.ts | 33 ++++++++++++++---- .../rectification-adopted-chart-tiers.test.ts | 34 ++++++++++++++++--- 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ecfdfa7e..99307c0e 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -15235,6 +15235,7 @@ - 修复:新模块 `rectification-adopted-chart-tiers.ts` 按来源记录读该 Case 的档案(`get_agentic_rectification_case_dossier`,服务账号),解析 `segment_summary`(采用的那次结果;若之后同一 Case 有更新的结果则用更新的,不跨 Case),5 分钟缓存;共用的范围读取函数在有不可判分盘时附带 `unreliableVargas`。聊天把它写进排盘工具输入,工作流调用时从发给引擎的载荷里剔除,写入 `answer_policy.unreliable_vargas` / `deterministic_claims_forbidden_for` 与一句服务端说明(VOICE「采用后判断不了的分盘」)。为不改另一单正在改的 `consult/route.ts`,挂接点放在路由已经注入的范围读取函数里。 - 验证:`tests/rectification-adopted-chart-tiers.test.ts`(segment-v1 / 旧采用 / 未校正 / 结果缺失 / 摘要损坏五种输入、跨 Case 拒绝、缓存);`tests/rectification-chart-tier-caveats-consult.test.ts`(范围读取 A/B、工具输入 A/B 与旧资料字节快照、引擎载荷不含该字段、模型数据包带策略与说明);旧资料聊天工具输入、引擎请求、工作流上下文、模型数据包与基线 `437dcecb` 逐字节相同(脚本见 PROGRESS);`tests/database-segment-adoption.test.ts` 追加真库读取断言(PostgreSQL 17 本地替身,正式证据待真 Docker)。 - 防复发:读取失败只记日志不阻断聊天;非 segment-v1 资料不发 RPC;模型侧沿用 `mastra/index.ts` 既有「`deterministic_claims_forbidden_for` 是硬禁令」规则,不新增提示词。 +- Claude 验收修正(2026-10-01):执行方实现总读 Case 最新结果、只看档位;但存下的分钟不随新结果变,且 D7 在头段无交集时退回 D1 头段中点,存盘分钟的 D9/D10 可能不在该盘头段——只看档位会把存盘分钟读不到的星座说成「较可信」(放宽置信边界)。改为 `unreliableChartsForSavedMinute`:档位不可判,或(非全窗唯一且)存盘分钟 `window_offset_minutes` 不在该盘头段,或偏移缺失,均标不可判。新增两条测试(存盘分钟落在非头段 → D9 标注;偏移缺失 → 所有窗内会变的盘标注),原测试 fixture 补真实采用必带的 `window_offset_minutes`(原值:无该字段 / 新值:60 或该 summary 的 adoption_minute.offset / 原因:真实 segment-v1 来源恒含此字段,新规则依赖它)。 - 相关记录:BUG-1115~1117、BUG-1131、BUG-1139、BUG-690 - 复发自:无 - 修复版本:`codex/rectification-chart-tier-caveats-20261001`(未合入、未部署) diff --git a/docs/tasks/PROGRESS-rectification-chart-tier-caveats-20261001.md b/docs/tasks/PROGRESS-rectification-chart-tier-caveats-20261001.md index 68239bc1..019a3e73 100644 --- a/docs/tasks/PROGRESS-rectification-chart-tier-caveats-20261001.md +++ b/docs/tasks/PROGRESS-rectification-chart-tier-caveats-20261001.md @@ -76,3 +76,11 @@ Linux,Node 22.14(`/exec-daemon/node`),Python 3.13(系统 `python3`, - 写作模型报告路径(`PERSONAL_REPORT_WRITER_ENABLED = false`)未接 `demoteThemesMissingRequiredCharts`;该路径生产关闭,开启前需补。 - 只给图盘 / 数值位置表加提示,未给依赖 D9/D10 的主题段落(报告正文为引擎输出,无稳定的主题→分盘映射);若要按主题降级需引擎侧配合,另立单。 - 已生成的旧报告不回改(任务书 D3)。 + +## Claude 验收修正(2026-10-01) + +| 项 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| 分盘可靠性判定 | 最新结果 `segment_summary` 中档位为 blocked / indistinct 即不可判 | 另加:非全窗唯一的分盘,存盘分钟(来源记录 `window_offset_minutes`)不在该盘头段,或偏移缺失,也不可判(`unreliableChartsForSavedMinute`) | 存盘分钟不随采用后的新结果变;D7 头段无交集时退回 D1 头段中点;只看档位会把存盘分钟读不到的星座标为较可信,放宽置信边界(AGENTS §8.4) | +| `rectification-adopted-chart-tiers.test.ts` fixture | 来源记录无 `window_offset_minutes` | 补该字段(60 / summary.adoption_minute.offset) | 真实 segment-v1 来源恒含此字段 | +| 新增测试 | — | 存盘分钟落在 D9 非头段 → 标注 D9;偏移缺失 → 窗内会变的盘全部标注 | 锁定上面的规则 | diff --git a/frontend/src/lib/rectification-adopted-chart-tiers.ts b/frontend/src/lib/rectification-adopted-chart-tiers.ts index 5d607646..e401b0f4 100644 --- a/frontend/src/lib/rectification-adopted-chart-tiers.ts +++ b/frontend/src/lib/rectification-adopted-chart-tiers.ts @@ -13,7 +13,7 @@ import { parseSegmentSummary, TARGET_CHARTS, type SegmentSummary, type TargetCha export const UNRELIABLE_CHART_TIERS = new Set(["blocked", "indistinct"]); export const CASE_DOSSIER_RPC = "get_agentic_rectification_case_dossier"; -export type SegmentProvenance = Readonly<{ caseId: string; resultId: string }>; +export type SegmentProvenance = Readonly<{ caseId: string; resultId: string; windowOffset: number | null }>; export type AdoptedChartTiers = Readonly<{ source: "segment-v1"; @@ -38,7 +38,9 @@ export function segmentProvenance(value: unknown): SegmentProvenance | null { if (!row || row.contract !== "segment-v1") return null; const caseId = typeof row.case_id === "string" ? row.case_id : ""; const resultId = typeof row.result_id === "string" ? row.result_id : ""; - return uuidPattern.test(caseId) && uuidPattern.test(resultId) ? { caseId, resultId } : null; + const offset = row.window_offset_minutes; + const windowOffset = typeof offset === "number" && Number.isInteger(offset) && offset >= 0 ? offset : null; + return uuidPattern.test(caseId) && uuidPattern.test(resultId) ? { caseId, resultId, windowOffset } : null; } export function unreliableChartsFromSummary(summary: SegmentSummary): TargetChart[] { @@ -46,9 +48,28 @@ export function unreliableChartsFromSummary(summary: SegmentSummary): TargetChar } /** - * Prefer the adopted result. A later result of the same Case (answers after - * adoption) carries strictly more evidence about the same window, so its tiers - * are used instead; a different Case is never read. + * A chart is reliable for the saved minute only when its tier is not unreliable and the + * saved minute sits in that chart's head segment (or the chart never changes in the window). + * D7 adopts the head-intersection midpoint and falls back to the D1 head when the heads do not + * intersect, and a later result may move a head after adoption, so the tier alone can claim a + * sign the saved minute does not have. Missing offset is treated as unknown: not reliable. + */ +export function unreliableChartsForSavedMinute(summary: SegmentSummary, windowOffset: number | null): TargetChart[] { + return TARGET_CHARTS.filter((chart) => { + const row = summary.charts.find((item) => item.chart === chart); + if (!row) return false; + if (UNRELIABLE_CHART_TIERS.has(row.tier)) return true; + if (row.full_window_unique) return false; + if (windowOffset === null) return true; + const segment = row.segments.find((item) => item.start <= windowOffset && windowOffset <= item.end); + return !segment || segment.index !== row.top_segment; + }); +} + +/** + * Reads the Case's latest result (the adopted one, or a later one after more answers) and + * judges each chart against the saved minute, not the result's own head: see + * unreliableChartsForSavedMinute. A different Case is never read. */ export function chartTiersFromDossier(dossier: unknown, provenance: SegmentProvenance): AdoptedChartTiers | "invalid" { const root = record(dossier); @@ -65,7 +86,7 @@ export function chartTiersFromDossier(dossier: unknown, provenance: SegmentProve return { source: "segment-v1", resultMatch: resultId === provenance.resultId ? "adopted" : "latest", - unreliable: unreliableChartsFromSummary(summary), + unreliable: unreliableChartsForSavedMinute(summary, provenance.windowOffset), }; } diff --git a/frontend/tests/rectification-adopted-chart-tiers.test.ts b/frontend/tests/rectification-adopted-chart-tiers.test.ts index cdc2ff26..4cc22001 100644 --- a/frontend/tests/rectification-adopted-chart-tiers.test.ts +++ b/frontend/tests/rectification-adopted-chart-tiers.test.ts @@ -18,7 +18,7 @@ const userId = "11111111-1111-4111-8111-111111111111"; const caseId = "22222222-2222-4222-8222-222222222222"; const resultId = "33333333-3333-4333-8333-333333333333"; const newerResultId = "44444444-4444-4444-8444-444444444444"; -const segmentV1 = { contract: "segment-v1", case_id: caseId, result_id: resultId, candidate_time: "09:05" }; +const segmentV1 = { contract: "segment-v1", case_id: caseId, result_id: resultId, candidate_time: "09:05", window_offset_minutes: 60 }; // Real 31-minute scan: D1 is one sign (certain); every candidate weighs on the // first D9/D10 segments so the posterior picks them. @@ -49,7 +49,8 @@ function client(data: unknown, error: unknown = null) { } test("provenance: only segment-v1 with uuid case/result is read", () => { - assert.deepEqual(segmentProvenance(segmentV1), { caseId, resultId }); + assert.deepEqual(segmentProvenance(segmentV1), { caseId, resultId, windowOffset: 60 }); + assert.deepEqual(segmentProvenance({ ...segmentV1, window_offset_minutes: undefined }), { caseId, resultId, windowOffset: null }); for (const value of [null, {}, { contract: "candidate-v2", case_id: caseId, result_id: resultId }, { ...segmentV1, case_id: "x" }, "segment-v1"]) { assert.equal(segmentProvenance(value), null); } @@ -88,7 +89,8 @@ test("real narrow scan: certain D1 and credible D9/D10 are not flagged", () => { const summary = realSummary(); const tiers = Object.fromEntries(summary.charts.map((chart) => [chart.chart, chart.tier])); assert.equal(tiers.D1, "certain"); - const result = chartTiersFromDossier(dossier(summary), { caseId, resultId }); + // The saved minute is what D7 adopted from this very summary. + const result = chartTiersFromDossier(dossier(summary), { caseId, resultId, windowOffset: summary.adoption_minute!.offset }); assert.notEqual(result, "invalid"); if (result === "invalid") return; assert.deepEqual(result.unreliable, summary.charts.filter((chart) => chart.tier === "blocked" || chart.tier === "indistinct").map((chart) => chart.chart)); @@ -96,9 +98,9 @@ test("real narrow scan: certain D1 and credible D9/D10 are not flagged", () => { }); test("a newer result of the same Case is read; another Case never is", () => { - const newer = chartTiersFromDossier(dossier(wideSummary(), newerResultId), { caseId, resultId }); + const newer = chartTiersFromDossier(dossier(wideSummary(), newerResultId), { caseId, resultId, windowOffset: 60 }); assert.deepEqual(newer, { source: "segment-v1", resultMatch: "latest", unreliable: ["D9", "D10"] }); - assert.equal(chartTiersFromDossier(dossier(wideSummary(), resultId, "55555555-5555-4555-8555-555555555555"), { caseId, resultId }), "invalid"); + assert.equal(chartTiersFromDossier(dossier(wideSummary(), resultId, "55555555-5555-4555-8555-555555555555"), { caseId, resultId, windowOffset: 60 }), "invalid"); }); test("results are cached per adopted result for five minutes", async () => { @@ -113,3 +115,25 @@ test("results are cached per adopted result for five minutes", async () => { await loadAdoptedChartTiers(reader, { userId, provenance: segmentV1 }, () => {}, () => now); assert.equal(reader.calls.length, 2); }); + +// Reviewer addition (Claude 2026-10-01): reliability is judged for the saved minute, not the head. +test("a credible chart is still flagged when the saved minute is outside its head segment", () => { + const summary = realSummary(); + const d9 = summary.charts.find((chart) => chart.chart === "D9")!; + assert.ok(!["blocked", "indistinct"].includes(d9.tier) && !d9.full_window_unique, "fixture: D9 credible and changing"); + const outside = d9.segments.find((segment) => segment.index !== d9.top_segment)!; + const moved = chartTiersFromDossier(dossier(summary, newerResultId), { caseId, resultId, windowOffset: outside.start }); + assert.notEqual(moved, "invalid"); + if (moved === "invalid") return; + assert.equal(moved.resultMatch, "latest"); + assert.ok(moved.unreliable.includes("D9"), "saved minute's D9 differs from the head the card would name"); + assert.ok(!moved.unreliable.includes("D1"), "D1 never changes in this window"); +}); + +test("an unknown saved-minute offset flags every chart that changes in the window", () => { + const summary = realSummary(); + const result = chartTiersFromDossier(dossier(summary), { caseId, resultId, windowOffset: null }); + assert.notEqual(result, "invalid"); + if (result === "invalid") return; + assert.deepEqual(result.unreliable, summary.charts.filter((chart) => !chart.full_window_unique).map((chart) => chart.chart)); +});