From 0cc9d087c336de40b83e1129bed9c542df2ba0f6 Mon Sep 17 00:00:00 2001 From: robbond Date: Fri, 2 Oct 2026 06:48:24 +0100 Subject: [PATCH] fix(confidence-engine): permit empty plausibleInterpretations for honest zero state - Zod validator: plausibleInterpretations accepts empty string (zero interpretations) while remaining required and string-typed - Prompt instruction: SECTION B now explicitly requests "" instead of absence prose when no plausible inputs exist - Report generation/update paths derive hasPlausibleInterpretations from actual synthesized data rather than hard-coding true - Tests: updated validation contract, added hasPlausibleInterpretations derivation assertions, updated seam tests to use derived values --- app/investigations/[id]/report/page.jsx | 4 +- lib/graph/investigation-overview-synthesis.js | 4 +- .../investigation-overview-synthesis.test.js | 69 ++++++++++++++++--- tests/ui/v060h-report-identity.test.jsx | 4 +- 4 files changed, 67 insertions(+), 14 deletions(-) diff --git a/app/investigations/[id]/report/page.jsx b/app/investigations/[id]/report/page.jsx index 137b7e3..0f68df9 100644 --- a/app/investigations/[id]/report/page.jsx +++ b/app/investigations/[id]/report/page.jsx @@ -63,7 +63,7 @@ export default function ReportPage({ params }) { if (data.success) { /* ── v0.59a — provenance: record generation revision (does NOT change Investigation revision) ── */ const rev = existing?.investigationRevision ?? 0; - const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev }; + const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: (data.plausibleInterpretations ?? "").trim().length > 0, generatedFromRevision: rev }; setExisting((p) => { void saveInvestigation({ ...p, investigationReport: reportData }) .catch((error) => console.error("Investigation report save failed", error)); @@ -109,7 +109,7 @@ export default function ReportPage({ params }) { const data = await res.json(); if (data.success) { - const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev }; + const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: (data.plausibleInterpretations ?? "").trim().length > 0, generatedFromRevision: rev }; setExisting((p) => { void saveInvestigation({ ...p, investigationReport: reportData }) .catch((error) => console.error("Investigation report save failed", error)); diff --git a/lib/graph/investigation-overview-synthesis.js b/lib/graph/investigation-overview-synthesis.js index a60451a..464648d 100644 --- a/lib/graph/investigation-overview-synthesis.js +++ b/lib/graph/investigation-overview-synthesis.js @@ -28,7 +28,7 @@ export { filterEligibleFindings }; const overviewResponseSchema = z.object({ understanding: z.string().min(1), - plausibleInterpretations: z.string().min(1), + plausibleInterpretations: z.string(), }); const overviewOutputSchema = { @@ -210,7 +210,7 @@ ${interpSections.length > 0 ? JSON.stringify(interpSections, null, 2) : "(none)" 9. Neither field may contain recommendations, decisions, confidence scores, next actions, priorities, or readiness assessments. 10. This is a FRESH synthesis — do NOT treat any previous overview or Current Understanding as input. -Both fields must be non-empty strings.`; +understanding must be a non-empty string. When SECTION B has no plausible inputs, return plausibleInterpretations as an empty string ("") — do NOT manufacture an absence statement.`; return prompt; } diff --git a/tests/graph/investigation-overview-synthesis.test.js b/tests/graph/investigation-overview-synthesis.test.js index 033c2c2..a003dc8 100644 --- a/tests/graph/investigation-overview-synthesis.test.js +++ b/tests/graph/investigation-overview-synthesis.test.js @@ -235,9 +235,18 @@ describe("investigation overview — output validation contract", () => { expect(result.valid).toBe(false); }); - it("empty string plausibleInterpretations rejected", () => { + it("empty string plausibleInterpretations accepted (zero interpretations)", () => { const result = validateOverviewResponse({ understanding: "something", plausibleInterpretations: "" }); - expect(result.valid).toBe(false); + expect(result.valid).toBe(true); + expect(result.data.plausibleInterpretations).toBe(""); + }); + + it("valid empty-string plausibleInterpretations with non-empty understanding is canonical zero", () => { + const result = validateOverviewResponse({ + understanding: "Supported understanding.", + plausibleInterpretations: "", + }); + expect(result.valid).toBe(true); }); it("malformed JSON rejected", () => { @@ -245,17 +254,61 @@ describe("investigation overview — output validation contract", () => { expect(result.valid).toBe(false); }); - it("null input rejected", () => { - expect(validateOverviewResponse(null).valid).toBe(false); - }); - - it("undefined input rejected", () => { - expect(validateOverviewResponse(undefined).valid).toBe(false); + it("non-empty plausible synthesis remains valid", () => { + const result = validateOverviewResponse({ + understanding: "Supported understanding.", + plausibleInterpretations: "Y is still a candidate.", + }); + expect(result.valid).toBe(true); }); it("non-object input rejected", () => { expect(validateOverviewResponse("just a string").valid).toBe(false); }); + + // ── hasPlausibleInterpretations derivation from validated data ── + + describe("hasPlausibleInterpretations derivation", () => { + it("zero plausible output derives hasPlausibleInterpretations === false", () => { + const result = validateOverviewResponse({ + understanding: "Supported understanding.", + plausibleInterpretations: "", + }); + expect(result.valid).toBe(true); + const hasPlausibleInterpretations = result.data.plausibleInterpretations.trim().length > 0; + expect(hasPlausibleInterpretations).toBe(false); + }); + + it("non-zero plausible output derives hasPlausibleInterpretations === true", () => { + const result = validateOverviewResponse({ + understanding: "Supported understanding.", + plausibleInterpretations: "Some interpretation.", + }); + expect(result.valid).toBe(true); + const hasPlausibleInterpretations = result.data.plausibleInterpretations.trim().length > 0; + expect(hasPlausibleInterpretations).toBe(true); + }); + + it("whitespace-only plausibleInterpretations derives false", () => { + const result = validateOverviewResponse({ + understanding: "Supported.", + plausibleInterpretations: " ", + }); + expect(result.valid).toBe(true); + const hasPlausibleInterpretations = result.data.plausibleInterpretations.trim().length > 0; + expect(hasPlausibleInterpretations).toBe(false); + }); + }); + + // ── SECTION B empty-input prompt contract ── + + describe("SECTION B empty-input prompt instruction", () => { + it("prompt explicitly permits and requests empty-string output when no plausible inputs", () => { + const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, []); + expect(prompt).toContain('plausibleInterpretations'); + expect(prompt).toMatch(/empty string/i); + }); + }); }); // ── 6. No decision/recommendation/readiness fields allowed ─────────────── diff --git a/tests/ui/v060h-report-identity.test.jsx b/tests/ui/v060h-report-identity.test.jsx index 74f2017..7d0bf81 100644 --- a/tests/ui/v060h-report-identity.test.jsx +++ b/tests/ui/v060h-report-identity.test.jsx @@ -145,7 +145,7 @@ describe("Report page identity and lifecycle — v0.60h-a (seam-level)", () => { const resp = await fetch("/api/cases/overview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ situationGraph: loaded?.situationGraph, findings: loaded?.findings || [] }) }); expect(resp.ok).toBe(true); - await (await import("@/lib/storage/investigation-storage")).saveInvestigation({ ...loaded, investigationReport: { understanding: "Generated.", hasPlausibleInterpretations: false, generatedFromRevision: 4 } }); + await (await import("@/lib/storage/investigation-storage")).saveInvestigation({ ...loaded, investigationReport: { understanding: "Generated.", plausibleInterpretations: null, hasPlausibleInterpretations: (null ?? "").trim().length > 0, generatedFromRevision: 4 } }); expect(mockLastSavedSnapshot.id).toBe("inv-report-a"); expect(mockLastSavedSnapshot.investigationReport.generatedFromRevision).toBe(4); }); @@ -189,7 +189,7 @@ describe("Report page identity and lifecycle — v0.60h-a (seam-level)", () => { const resp = await fetch("/api/cases/overview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ situationGraph: loaded?.situationGraph, findings: loaded?.findings }) }); expect(resp.ok).toBe(true); - await (await import("@/lib/storage/investigation-storage")).saveInvestigation({ ...loaded, investigationReport: { understanding: "R2.", hasPlausibleInterpretations: false, generatedFromRevision: 5 } }); + await (await import("@/lib/storage/investigation-storage")).saveInvestigation({ ...loaded, investigationReport: { understanding: "R2.", plausibleInterpretations: null, hasPlausibleInterpretations: (null ?? "").trim().length > 0, generatedFromRevision: 5 } }); expect(mockLastSavedSnapshot.id).toBe("inv-report-a"); expect(mockLastSavedSnapshot.investigationReport.generatedFromRevision).toBe(5); });