import { describe, expect, it, vi } from "vitest"; import { filterEligibleFindings, buildOverviewSynthesisPrompt, synthesizeInvestigationOverview, validateOverviewResponse, } from "@/lib/graph/investigation-overview-synthesis.js"; // ── Fixtures ──────────────────────────────────────────────── const graph = { centralStatement: "Revenue declined while churn increased.", nodes: [ { id: "n1", label: "Revenue down 12%", description: "Primary metric baseline", kind: "metric", status: "known", confidence: "high", value: 12, unit: "%" }, { id: "n2", label: "Customer churn up 8%", description: "Secondary metric baseline", kind: "metric", status: "supported", confidence: "medium", value: 8, unit: "%" }, { id: "n3", label: "Open question about baseline period", description: "Unknown", kind: "unknown", status: "unknown", confidence: "low" }, { id: "n4", label: "Resolved time period question", description: "Resolved answer", kind: "question", status: "resolved", confidence: "high" }, { id: "n5", label: "Provisional hypothesis about market conditions", description: "Not yet tested", kind: "hypothesis", status: "provisional", confidence: "low" }, ], edges: [{ fromNodeId: "n1", toNodeId: "n2", relationship: "correlates_with" }], activeUnknownNodeId: "n3", resolvedNodeIds: ["n4"], currentSummary: "Sentinel: must not appear", }; const eligibleFindings = [ { id: "f1", proposition: "Revenue decline matches sector trend.", userDisposition: "agree" }, { id: "f2", proposition: "Churn increase correlates with pricing change date.", userDisposition: null }, { id: "f3", proposition: "This finding is not relevant.", userDisposition: "not_relevant" }, { id: "f4", proposition: "Rejected finding.", evaluation: "rejected", userDisposition: null }, ]; const plausibleInterps = [ { id: "p1", description: "Market-wide downturn could explain revenue decline.", confidence: "medium", supportingEvidenceIds: ["n1"] }, { id: "p2", description: "Product feature regression might cause churn increase.", confidence: "low", supportingEvidenceIds: ["n2"] }, ]; const fakeProvider = (defaultResponse) => ({ generateReconstruction: vi.fn(() => { return typeof defaultResponse === "function" ? defaultResponse() : JSON.stringify({ understanding: "Synthesized understanding from evidence.", plausibleInterpretations: "Remaining plausible explanations.", }); }), }); // ── 1. Supported evidence is available to the understanding synthesis ─────────────── describe("investigation overview — evidence availability", () => { it("known node content flows to understanding section input", () => { const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps); expect(prompt).toContain("Revenue down 12%"); expect(prompt).toContain("[known]"); expect(prompt).toContain("Known Facts:"); }); it("supported node content flows to understanding section input", () => { const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps); expect(prompt).toContain("Customer churn up 8%"); expect(prompt).toContain("[supported]"); expect(prompt).toContain("Supported Inferences:"); }); it("eligible agreed Finding flows to understanding section input", () => { const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps); expect(prompt).toContain("Revenue decline matches sector trend."); expect(prompt).toContain("Confirmed Evidence"); }); it("eligible working premise Finding flows to understanding section input", () => { const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps); expect(prompt).toContain("Churn increase correlates with pricing change date."); expect(prompt).toContain("Working Premises"); }); it("plausible interpretations flow to separate section input", () => { const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps); expect(prompt).toContain("Market-wide downturn could explain revenue decline."); expect(prompt).toContain("Plausible Interpretations:"); expect(prompt).toContain("SECTION B INPUTS"); }); it("centralStatement flows as framing context", () => { const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps); expect(prompt).toContain("Revenue declined while churn increased."); }); }); // ── 2. Unresolved graph material is excluded ─────────────────────── describe("investigation overview — unresolved material excluded", () => { it("unknown nodes do NOT appear in the overview prompt", () => { const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps); expect(prompt).not.toContain("Open question about baseline period"); expect(prompt).not.toContain('status":"unknown"'); }); it("provisional nodes do NOT appear in the overview prompt", () => { const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps); expect(prompt).not.toContain("Provisional hypothesis about market conditions"); expect(prompt).not.toContain('status":"provisional"'); }); it("resolved node text does NOT appear in the overview prompt", () => { const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps); expect(prompt).not.toContain("Resolved time period question"); expect(prompt).not.toContain('status":"resolved"'); }); it("control fields excluded from overview prompt", () => { const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps); expect(prompt).not.toContain("currentSummary"); expect(prompt).not.toContain("Sentinel: must not appear"); expect(prompt).not.toContain("reasoningState"); expect(prompt).not.toContain("activeUnknownNodeId"); expect(prompt).not.toContain("resolvedNodeIds"); }); it("edges do NOT appear in the overview prompt", () => { const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps); expect(prompt).not.toContain("correlates_with"); }); it("ineligible findings (not_relevant + rejected) excluded from evidence input", () => { const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps); expect(prompt).not.toContain("This finding is not relevant."); expect(prompt).not.toContain("Rejected finding."); }); }); // ── 3. Plausible interpretations remain separately represented ─────────────── describe("investigation overview — interpretation separation", () => { it("plausible interpretations appear only in SECTION B, not SECTION A", () => { const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps); // They should appear under Plausible Interpretations (section B) expect(prompt).toContain("Plausible Interpretations:"); // And also appear once in the document (in section B only) const lines = prompt.split("\n"); let foundInSectionA = false; let foundInSectionB = false; let currentSection = null; for (const line of lines) { if (line.includes("SECTION A")) currentSection = "a"; if (line.includes("SECTION B")) currentSection = "b"; if (currentSection === "a" && line.includes("Product feature regression")) foundInSectionA = true; if (currentSection === "b" && line.includes("Product feature regression")) foundInSectionB = true; } expect(foundInSectionA).toBe(false); expect(foundInSectionB).toBe(true); }); it("empty plausible interpretations handled gracefully", () => { const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, []); expect(prompt).toContain("SECTION B INPUTS"); // When empty, "(none)" indicates no interpretations — acceptable behavior expect(prompt).toContain("SECTION B INPUTS — Plausible Interpretations"); }); }); // ── 4. Plausible interpretations NOT inserted into evidence-backed projection ─────────────── describe("investigation overview — epistemic boundary integrity", () => { it("plausible interpretations are never included in the known+supported node projection", () => { // Create a graph without any plausible interpretation nodes (which would be status "provisional") const cleanGraph = { centralStatement: "Test scenario.", nodes: [ { id: "a1", label: "Confirmed fact", description: "Evidence", kind: "metric", status: "known" }, { id: "a2", label: "Supported inference", description: "Evidence", kind: "metric", status: "supported" }, ], }; // Even when plausibleInterps are passed, they must not enter the evidence section const prompt = buildOverviewSynthesisPrompt(cleanGraph, [], [{ id: "x1", description: "A plausible interp with its own id and description.", confidence: "low" }]); // SECTION A has only a1/a2 content — no interpretation leakage expect(prompt).toContain("Confirmed fact"); expect(prompt).toContain("[known]"); }); it("passing plausibleInterpretations with no graph evidence still produces valid prompt", () => { const minimalGraph = { centralStatement: "Minimal." }; const prompt = buildOverviewSynthesisPrompt(minimalGraph, [], plausibleInterps); expect(prompt).toContain("SECTION A INPUTS"); expect(prompt).toContain("SECTION B INPUTS"); expect(prompt).not.toContain("[known]"); expect(prompt).not.toContain("[supported]"); }); it("prompt contains explicit epistemic boundary rules", () => { const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps); expect(prompt).toContain("Section A (understanding) MUST contain only established or supported understanding"); expect(prompt).toContain("never promoted into Section A"); expect(prompt).toContain("Do NOT introduce any new facts"); // SECTION B must also be present for interpretation content expect(prompt).toContain("SECTION B INPUTS"); }); }); // ── 5. Output contract — structurally distinct fields ─────────────── describe("investigation overview — output validation contract", () => { it("valid two-field response accepted", () => { const result = validateOverviewResponse({ understanding: "We understand X.", plausibleInterpretations: "Y remains plausible.", }); expect(result.valid).toBe(true); expect(result.data.understanding).toBe("We understand X."); expect(result.data.plausibleInterpretations).toBe("Y remains plausible."); }); it("valid JSON string response accepted", () => { const result = validateOverviewResponse(JSON.stringify({ understanding: "parsed", plausibleInterpretations: "also parsed" })); expect(result.valid).toBe(true); }); it("missing understanding field rejected", () => { const result = validateOverviewResponse({ plausibleInterpretations: "Only one field" }); expect(result.valid).toBe(false); }); it("missing plausibleInterpretations field rejected", () => { const result = validateOverviewResponse({ understanding: "Only one field" }); expect(result.valid).toBe(false); }); it("empty string understanding rejected", () => { const result = validateOverviewResponse({ understanding: "", plausibleInterpretations: "something" }); expect(result.valid).toBe(false); }); it("empty string plausibleInterpretations rejected", () => { const result = validateOverviewResponse({ understanding: "something", plausibleInterpretations: "" }); expect(result.valid).toBe(false); }); it("malformed JSON rejected", () => { const result = validateOverviewResponse("not json [[["); 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-object input rejected", () => { expect(validateOverviewResponse("just a string").valid).toBe(false); }); }); // ── 6. No decision/recommendation/readiness fields allowed ─────────────── describe("investigation overview — no forbidden epistemic leakage", () => { it("recommendation field rejected by validator", () => { const result = validateOverviewResponse({ understanding: "We understand X.", plausibleInterpretations: "Y is plausible.", recommendation: "Do Y next.", }); expect(result.valid).toBe(false); expect(result.reason).toContain("forbidden_field"); }); it("decision field rejected by validator", () => { const result = validateOverviewResponse({ understanding: "We understand X.", plausibleInterpretations: "Y is plausible.", decision: "Proceed with Y.", }); expect(result.valid).toBe(false); }); it("confidenceScore field rejected by validator", () => { const result = validateOverviewResponse({ understanding: "We understand X.", plausibleInterpretations: "Y is plausible.", confidenceScore: 0.8, }); expect(result.valid).toBe(false); }); it("nextAction field rejected by validator", () => { const result = validateOverviewResponse({ understanding: "We understand X.", plausibleInterpretations: "Y is plausible.", nextAction: "Investigate Y.", }); expect(result.valid).toBe(false); }); it("priority field rejected by validator", () => { const result = validateOverviewResponse({ understanding: "We understand X.", plausibleInterpretations: "Y is plausible.", priority: "high", }); expect(result.valid).toBe(false); }); it("readiness field rejected by validator", () => { const result = validateOverviewResponse({ understanding: "We understand X.", plausibleInterpretations: "Y is plausible.", readiness: "ready", }); expect(result.valid).toBe(false); }); }); // ── Domain function seam tests (full overview synthesis) ─────────────── describe("synthesizeInvestigationOverview — full seam", () => { it("produces structured overview output from valid inputs", async () => { const fake = fakeProvider(); const result = await synthesizeInvestigationOverview( { situationGraph: graph, findings: eligibleFindings, plausibleInterpretations: plausibleInterps }, { provider: fake } ); expect(result).toHaveProperty("understanding"); expect(result).toHaveProperty("plausibleInterpretations"); expect(typeof result.understanding).toBe("string"); expect(typeof result.plausibleInterpretations).toBe("string"); expect(fake.generateReconstruction).toHaveBeenCalledTimes(1); }); it("provider receives prompt with evidence-authority boundary rules", async () => { const fake = fakeProvider(); await synthesizeInvestigationOverview( { situationGraph: graph, findings: eligibleFindings, plausibleInterpretations: plausibleInterps }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; expect(prompt).toContain("EPISTEMIC BOUNDARY RULES"); expect(prompt).toContain("SECTION A INPUTS"); expect(prompt).toContain("SECTION B INPUTS"); expect(prompt).toContain("never promoted into Section A"); }); it("unknown nodes do NOT appear in provider-visible prompt", async () => { const fake = fakeProvider(); await synthesizeInvestigationOverview( { situationGraph: graph, findings: eligibleFindings, plausibleInterpretations: plausibleInterps }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; expect(prompt).not.toContain("Open question about baseline period"); }); it("provisional nodes do NOT appear in provider-visible prompt", async () => { const fake = fakeProvider(); await synthesizeInvestigationOverview( { situationGraph: graph, findings: eligibleFindings, plausibleInterpretations: plausibleInterps }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; expect(prompt).not.toContain("Provisional hypothesis"); }); it("plausible interpretations remain in SECTION B, not SECTION A", async () => { const fake = fakeProvider(); await synthesizeInvestigationOverview( { situationGraph: graph, findings: eligibleFindings, plausibleInterpretations: plausibleInterps }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; // Should only appear under SECTION B expect(prompt).toContain("Plausible Interpretations:"); }); it("missing situationGraph throws", async () => { await expect( synthesizeInvestigationOverview({}, { provider: fakeProvider() }) ).rejects.toThrow(/situationGraph is required/); }); it("non-object situationGraph throws", async () => { await expect( synthesizeInvestigationOverview({ situationGraph: "not an object" }, { provider: fakeProvider() }) ).rejects.toThrow(/situationGraph is required/); }); it("findings as non-array throws", async () => { await expect( synthesizeInvestigationOverview({ situationGraph: graph, findings: "string" }, { provider: fakeProvider() }) ).rejects.toThrow(/findings must be an array/); }); it("plausibleInterpretations as non-array throws", async () => { await expect( synthesizeInvestigationOverview({ situationGraph: graph, plausibleInterpretations: "string" }, { provider: fakeProvider() }) ).rejects.toThrow(/plausibleInterpretations must be an array/); }); it("no provider throws", async () => { await expect( synthesizeInvestigationOverview({ situationGraph: graph, findings: [], plausibleInterpretations: [] }, {}) ).rejects.toThrow(/OLLAMA_BASE_URL/); }); it("invalid model response throws validation error", async () => { const fake = fakeProvider(async () => JSON.stringify({ wrongField: "value" })); await expect( synthesizeInvestigationOverview( { situationGraph: graph, findings: [], plausibleInterpretations: [] }, { provider: fake } ) ).rejects.toThrow(/validation failed/); }); it("provider throws → propagation", async () => { const fake = fakeProvider(async () => { throw new Error("down"); }); await expect( synthesizeInvestigationOverview( { situationGraph: graph, findings: [], plausibleInterpretations: [] }, { provider: fake } ) ).rejects.toThrow(/provider call failed/); }); it("graph and findings immutable after synthesis", async () => { const graphSnapshot = JSON.parse(JSON.stringify(graph)); const findingsSnapshot = JSON.parse(JSON.stringify(eligibleFindings)); const fake = fakeProvider(); await synthesizeInvestigationOverview( { situationGraph: graph, findings: eligibleFindings, plausibleInterpretations: plausibleInterps }, { provider: fake } ); expect(JSON.stringify(graph)).toBe(JSON.stringify(graphSnapshot)); expect(JSON.stringify(eligibleFindings)).toBe(JSON.stringify(findingsSnapshot)); }); it("modelName resolved from deps", async () => { const fake = fakeProvider(); await synthesizeInvestigationOverview( { situationGraph: graph, findings: [], plausibleInterpretations: [] }, { provider: fake, modelName: "test-model" } ); expect(fake.generateReconstruction.mock.calls[0][1]).toBe("test-model"); }); it("empty findings and interpretations produce valid synthesis call", async () => { const fake = fakeProvider(); await synthesizeInvestigationOverview( { situationGraph: graph, findings: [], plausibleInterpretations: [] }, { provider: fake } ); expect(fake.generateReconstruction).toHaveBeenCalledTimes(1); }); it("reusable filterEligibleFindings produces correct eligible set for overview", () => { const eligible = filterEligibleFindings(eligibleFindings); expect(eligible.map((f) => f.id)).toEqual(["f1", "f2"]); expect(eligible).toHaveLength(2); }); });