diff --git a/frontend/src/lib/rectification-adopted-chart-tiers.ts b/frontend/src/lib/rectification-adopted-chart-tiers.ts new file mode 100644 index 00000000..5d607646 --- /dev/null +++ b/frontend/src/lib/rectification-adopted-chart-tiers.ts @@ -0,0 +1,119 @@ +import { parseSegmentSummary, TARGET_CHARTS, type SegmentSummary, type TargetChart } from "./rectification-agentic/core/segment-summary.ts"; + +/** + * Which divisional charts a segment-v1 adoption could not determine (BUG-1138/1139). + * + * The rectification delivery card already says "D9 cannot be determined" for a + * wide window; reports and chat used to read every chart from the saved minute + * anyway. This module reads the adopted result's own segment summary so they can + * say the same thing. Anything else (older adoptions, unrectified profiles, a + * missing or malformed result) returns null and leaves callers unchanged. + */ + +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 AdoptedChartTiers = Readonly<{ + source: "segment-v1"; + /** "adopted": the summary of the adopted result; "latest": a newer result of the same Case. */ + resultMatch: "adopted" | "latest"; + unreliable: readonly TargetChart[]; +}>; + +type RpcResponse = PromiseLike<{ data: unknown; error: unknown }>; +export type AdoptedChartTierClient = Readonly<{ rpc: (fn: string, args: Record) => RpcResponse }>; +export type AdoptedChartTierWarn = (payload: Readonly<{ event: "adopted_chart_tiers_unavailable"; reason: string }>) => void; + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : null; +} + +/** Only the segment-v1 adoption contract carries a result whose summary describes the saved minute. */ +export function segmentProvenance(value: unknown): SegmentProvenance | null { + const row = record(value); + 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; +} + +export function unreliableChartsFromSummary(summary: SegmentSummary): TargetChart[] { + return TARGET_CHARTS.filter((chart) => summary.charts.some((row) => row.chart === chart && UNRELIABLE_CHART_TIERS.has(row.tier))); +} + +/** + * 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. + */ +export function chartTiersFromDossier(dossier: unknown, provenance: SegmentProvenance): AdoptedChartTiers | "invalid" { + const root = record(dossier); + const caseRow = record(root?.case); + const latest = record(root?.latest_result); + const caseId = typeof caseRow?.case_id === "string" ? caseRow.case_id : typeof caseRow?.id === "string" ? caseRow.id : null; + if (!root || !latest || (caseId !== null && caseId !== provenance.caseId)) return "invalid"; + const resultId = typeof latest.result_id === "string" ? latest.result_id : null; + if (!resultId) return "invalid"; + const receipt = record(latest.decision_receipt); + const state = record(receipt?.inference_state); + const summary = parseSegmentSummary(state?.segment_summary); + if (!summary) return "invalid"; + return { + source: "segment-v1", + resultMatch: resultId === provenance.resultId ? "adopted" : "latest", + unreliable: unreliableChartsFromSummary(summary), + }; +} + +const CACHE_TTL_MS = 5 * 60 * 1000; +const CACHE_LIMIT = 500; +const cache = new Map(); + +export function clearAdoptedChartTierCache(): void { + cache.clear(); +} + +function defaultWarn(payload: Readonly<{ event: string; reason: string }>): void { + console.warn(JSON.stringify(payload)); +} + +function errorCode(error: unknown): string { + if (error && typeof error === "object" && "code" in error && typeof error.code === "string" && error.code.trim()) return error.code.trim(); + if (error instanceof Error && error.name.trim()) return error.name; + return "unknown"; +} + +export async function loadAdoptedChartTiers( + client: AdoptedChartTierClient, + input: Readonly<{ userId: string; provenance: unknown }>, + warn: AdoptedChartTierWarn = defaultWarn, + now: () => number = Date.now, +): Promise { + const provenance = segmentProvenance(input.provenance); + if (!provenance) return null; + const key = `${input.userId}|${provenance.caseId}|${provenance.resultId}`; + const hit = cache.get(key); + if (hit && now() - hit.at < CACHE_TTL_MS) return hit.value; + try { + const result = await client.rpc(CASE_DOSSIER_RPC, { p_user_id: input.userId, p_case_id: provenance.caseId }); + if (result.error) { + warn({ event: "adopted_chart_tiers_unavailable", reason: errorCode(result.error) }); + return null; + } + const tiers = chartTiersFromDossier(result.data, provenance); + if (tiers === "invalid") { + warn({ event: "adopted_chart_tiers_unavailable", reason: "invalid_segment_summary" }); + return null; + } + if (cache.size >= CACHE_LIMIT) cache.delete(cache.keys().next().value!); + cache.set(key, { at: now(), value: tiers }); + return tiers; + } catch (error) { + warn({ event: "adopted_chart_tiers_unavailable", reason: errorCode(error) }); + return null; + } +} diff --git a/frontend/tests/rectification-adopted-chart-tiers.test.ts b/frontend/tests/rectification-adopted-chart-tiers.test.ts new file mode 100644 index 00000000..cdc2ff26 --- /dev/null +++ b/frontend/tests/rectification-adopted-chart-tiers.test.ts @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { buildSegmentSummary, type SegmentMinute } from "../src/lib/rectification-agentic/core/segment-summary.ts"; +import { + CASE_DOSSIER_RPC, + chartTiersFromDossier, + clearAdoptedChartTierCache, + loadAdoptedChartTiers, + segmentProvenance, +} from "../src/lib/rectification-adopted-chart-tiers.ts"; + +// Real native scan of a public Rodden-AA case (31-minute window), not a hand-built shape. +const golden = JSON.parse(readFileSync(new URL("./fixtures/varga-api-response.golden.json", import.meta.url), "utf8")); +const realMinutes = golden.scan.minutes as SegmentMinute[]; + +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" }; + +// Real 31-minute scan: D1 is one sign (certain); every candidate weighs on the +// first D9/D10 segments so the posterior picks them. +function realSummary() { + const candidates = realMinutes.filter((_, index) => index % 2 === 0).map((minute) => ({ time: minute.time, score: minute.offset <= 4 ? 12 : 1 })); + return buildSegmentSummary({ minutes: realMinutes, candidates, windowMinutes: realMinutes.length, scanComplete: true }); +} + +// Explicitly fictional ±60 control: a 121-minute window makes D9/D10 blocked by width alone. +function wideSummary() { + const minutes: SegmentMinute[] = Array.from({ length: 121 }, (_, offset) => ({ + offset, date: "2000-06-14", time: `${String(8 + Math.floor(offset / 60)).padStart(2, "0")}:${String(offset % 60).padStart(2, "0")}`, + signs: { D1: 4, D9: Math.floor(offset / 13) % 12, D10: Math.floor(offset / 12) % 12 }, + })); + return buildSegmentSummary({ minutes, candidates: [{ time: minutes[60]!.time, score: 10 }], windowMinutes: 121, scanComplete: true }); +} + +function dossier(summary: unknown, id = resultId, owningCase = caseId) { + return { case: { case_id: owningCase }, latest_result: { result_id: id, decision_receipt: { inference_state: { segment_summary: summary } } } }; +} + +function client(data: unknown, error: unknown = null) { + const calls: { fn: string; args: Record }[] = []; + return { + calls, + rpc: async (fn: string, args: Record) => { calls.push({ fn, args }); return { data, error }; }, + }; +} + +test("provenance: only segment-v1 with uuid case/result is read", () => { + assert.deepEqual(segmentProvenance(segmentV1), { caseId, resultId }); + 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); + } +}); + +test("five inputs: segment-v1 / legacy adoption / unrectified / missing result / corrupt summary", async () => { + clearAdoptedChartTierCache(); + const warnings: string[] = []; + const warn = (payload: { reason: string }) => { warnings.push(payload.reason); }; + + // 1. segment-v1 with a wide window: D9 and D10 blocked, D1 determined. + const wide = client(dossier(wideSummary())); + assert.deepEqual(await loadAdoptedChartTiers(wide, { userId, provenance: segmentV1 }, warn), + { source: "segment-v1", resultMatch: "adopted", unreliable: ["D9", "D10"] }); + assert.deepEqual(wide.calls, [{ fn: CASE_DOSSIER_RPC, args: { p_user_id: userId, p_case_id: caseId } }]); + + // 2. Legacy (candidate-minute) adoption and 3. unrectified profile: no RPC, null. + const untouched = client(dossier(wideSummary())); + assert.equal(await loadAdoptedChartTiers(untouched, { userId, provenance: { contract: "candidate-v2", case_id: caseId, result_id: resultId } }, warn), null); + assert.equal(await loadAdoptedChartTiers(untouched, { userId, provenance: null }, warn), null); + assert.equal(untouched.calls.length, 0); + + // 4. Result missing (RPC error, or a dossier without a latest result): null and a warning. + clearAdoptedChartTierCache(); + assert.equal(await loadAdoptedChartTiers(client(null, { code: "P0001" }), { userId, provenance: segmentV1 }, warn), null); + assert.equal(await loadAdoptedChartTiers(client({ case: { case_id: caseId } }), { userId, provenance: segmentV1 }, warn), null); + + // 5. Corrupt summary (tier inconsistent with its own shares): null and a warning. + const corrupt = JSON.parse(JSON.stringify(wideSummary())); + corrupt.charts[1].tier = "credible"; + assert.equal(await loadAdoptedChartTiers(client(dossier(corrupt)), { userId, provenance: segmentV1 }, warn), null); + assert.deepEqual(warnings, ["P0001", "invalid_segment_summary", "invalid_segment_summary"]); +}); + +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 }); + 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)); + assert.ok(!result.unreliable.includes("D1")); +}); + +test("a newer result of the same Case is read; another Case never is", () => { + const newer = chartTiersFromDossier(dossier(wideSummary(), newerResultId), { caseId, resultId }); + 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"); +}); + +test("results are cached per adopted result for five minutes", async () => { + clearAdoptedChartTierCache(); + const reader = client(dossier(wideSummary())); + let now = 1_000; + await loadAdoptedChartTiers(reader, { userId, provenance: segmentV1 }, () => {}, () => now); + now += 60_000; + await loadAdoptedChartTiers(reader, { userId, provenance: segmentV1 }, () => {}, () => now); + assert.equal(reader.calls.length, 1); + now += 5 * 60_000; + await loadAdoptedChartTiers(reader, { userId, provenance: segmentV1 }, () => {}, () => now); + assert.equal(reader.calls.length, 2); +});