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
This commit is contained in:
2026-10-02 06:48:24 +01:00
parent 8dc6e58d43
commit 0cc9d087c3
4 changed files with 67 additions and 14 deletions
+2 -2
View File
@@ -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));
@@ -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;
}
@@ -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 ───────────────
+2 -2
View File
@@ -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);
});