import { describe, expect, it, vi } from "vitest"; import { filterEligibleFindings, buildSynthesisPrompt, synthesizeCurrentUnderstanding, validateSynthesisResponse, } from "@/lib/graph/current-understanding-synthesis.js"; // ── Fixtures ──────────────────────────────────────────────── /** A graph where only centralStatement is present — no known/supported nodes */ const emptyGraph = { centralStatement: "Complaints increased by 35% while production increased by 40%.", }; /** Full scenario fixture matching the fixed semantic regression case */ const fullScenario = { centralStatement: "Complaints increased by 35% while production increased by 40%.", nodes: [ { id: "n1", label: "Complaint count increased by 35%", description: "Primary metric baseline", kind: "metric", status: "known", confidence: "high", value: 35, unit: "%" }, { id: "n2", label: "Production count increased by 40%", description: "Secondary metric baseline", kind: "metric", status: "supported", confidence: "medium", value: 40, unit: "%" }, { id: "n3", label: "Baseline period denominator volume", description: "Question about the baseline", kind: "unknown", status: "unknown", confidence: "low" }, { id: "n4", label: "Time period over which these percentage changes occurred", description: "Resolved question — user answered", kind: "question", status: "resolved", confidence: "high" }, { id: "n5", label: "Product mix may explain the complaint increase", description: "Provisional hypothesis for later investigation", kind: "hypothesis", status: "provisional", confidence: "low" }, ], edges: [ { fromNodeId: "n1", toNodeId: "n2", relationship: "compares_with", confidence: "high", description: "Correlation between complaints and production" }, ], activeUnknownNodeId: "n3", resolvedNodeIds: ["n4"], currentSummary: "Sentinel: currentSummary must not appear in synthesis", reasoningState: { stage: "analysis", status: "active", outcome: "pending" }, }; /** Minimal eligible Finding — userDisposition null (working premise) */ const workingFinding = () => ({ id: "find-working", proposition: "The reported percentage changes correspond to the last financial quarter.", evaluation: "considered", userDisposition: null, sourceObservation: "obs-1", contributionId: "contrib-001", }); /** Minimal eligible Finding — userDisposition agree */ const agreedFinding = () => ({ id: "find-agreed", proposition: "The percentage increase in complaints exceeds the threshold.", evaluation: "considered", userDisposition: "agree", sourceObservation: "obs-2", contributionId: "contrib-002", }); /** Fake provider factory for tests */ const makeFakeProvider = (defaultResponse) => ({ generateReconstruction: vi.fn(async () => { return typeof defaultResponse === "function" ? defaultResponse() : JSON.stringify({ currentUnderstanding: "Synthesized output" }); }), }); // ── Eligibility tests (unchanged — domain invariant) ──────── describe("filterEligibleFindings — eligibility contract", () => { it("includes null disposition → eligible", () => { const result = filterEligibleFindings([workingFinding()]); expect(result).toHaveLength(1); expect(result[0].userDisposition).toBeNull(); }); it("includes agree disposition → eligible", () => { const result = filterEligibleFindings([agreedFinding()]); expect(result).toHaveLength(1); expect(result[0].userDisposition).toBe("agree"); }); it("excludes not_quite disposition → ineligible", () => { expect(filterEligibleFindings([{ userDisposition: "not_quite" }])).toHaveLength(0); }); it("excludes not_relevant disposition → ineligible", () => { expect(filterEligibleFindings([{ userDisposition: "not_relevant" }])).toHaveLength(0); }); it("excludes rejected evaluation → excluded", () => { expect(filterEligibleFindings([{ evaluation: "rejected" }])).toHaveLength(0); }); it("mixed dispositions — only eligible pass through", () => { const results = filterEligibleFindings([ { id: "f1", userDisposition: null }, { id: "f2", userDisposition: "agree" }, { id: "f3", userDisposition: "not_quite" }, { id: "f4", userDisposition: "not_relevant" }, { evaluation: "rejected", id: "f5" }, ]); expect(results.map((f) => f.id)).toEqual(["f1", "f2"]); }); it("null/undefined/empty input returns empty array", () => { expect(filterEligibleFindings(null)).toEqual([]); expect(filterEligibleFindings(undefined)).toEqual([]); expect(filterEligibleFindings([])).toEqual([]); }); }); // ── Evidence projection — status filtering ────────────────── describe("buildSynthesisPrompt — evidence-authority boundary", () => { // ── A. known retained ──────────────────────────────────── it("known node appears in the synthesis prompt (label, kind, value, unit)", () => { const prompt = buildSynthesisPrompt(fullScenario, []); expect(prompt).toContain("Complaint count increased by 35%"); expect(prompt).toContain("kind=metric"); expect(prompt).toContain("[known]"); }); // ── B. supported retained ──────────────────────────────── it("supported node appears in the synthesis prompt", () => { const prompt = buildSynthesisPrompt(fullScenario, []); expect(prompt).toContain("Production count increased by 40%"); expect(prompt).toContain("[supported]"); }); // ── C. unknown excluded ────────────────────────────────── it("unknown node content does NOT appear anywhere in synthesis", () => { const prompt = buildSynthesisPrompt(fullScenario, []); expect(prompt).not.toContain("Baseline period denominator volume"); expect(prompt).not.toContain('status":"unknown"'); expect(prompt).not.toContain("question about the baseline"); }); // ── D. provisional excluded ────────────────────────────── it("provisional hypothesis does NOT appear as CU evidence", () => { const prompt = buildSynthesisPrompt(fullScenario, []); expect(prompt).not.toContain("Product mix may explain"); expect(prompt).not.toContain('status":"provisional"'); }); // ── E. resolved-question text excluded ─────────────────── it("resolved node label/description does NOT appear as CU evidence", () => { const prompt = buildSynthesisPrompt(fullScenario, []); expect(prompt).not.toContain("Time period over which these percentage changes occurred"); expect(prompt).not.toContain('status":"resolved"'); expect(prompt).not.toContain("Resolved question"); }); // ── F. learned Finding retained ────────────────────────── it("eligible Finding appears in synthesis — proves we exclude resolved question while retaining the answer", () => { const prompt = buildSynthesisPrompt(fullScenario, [workingFinding()]); expect(prompt).toContain("The reported percentage changes correspond to the last financial quarter."); expect(prompt).not.toContain("Time period over which these percentage changes occurred"); }); // ── G. control/reasoning content excluded ──────────────── it("control/reasoning sentinels are absent from synthesis prompt", () => { const prompt = buildSynthesisPrompt(fullScenario, []); expect(prompt).not.toContain("currentSummary"); expect(prompt).not.toContain("Sentinel: currentSummary must not appear in synthesis"); expect(prompt).not.toContain("reasoningState"); expect(prompt).not.toContain("activeUnknownNodeId"); expect(prompt).not.toContain('"n3"'); // active unknown node id sentinel expect(prompt).not.toContain("resolvedNodeIds"); expect(prompt).not.toContain('"n4"'); // resolved node id sentinel }); // ── H. centralStatement retained as framing ───────────── it("centralStatement remains available as framing context", () => { const prompt = buildSynthesisPrompt(fullScenario, []); expect(prompt).toContain("Complaints increased by 35% while production increased by 40%"); }); // ── I. node.proposition dead read removed ──────────────── it("prompt does NOT depend on a proposition property on SituationGraph nodes", () => { const noPropositionGraph = { centralStatement: "Only framing.", nodes: [ { id: "a1", label: "Label only node", description: "No proposition field", kind: "observation", status: "known", confidence: "medium" }, ], }; const prompt = buildSynthesisPrompt(noPropositionGraph, []); expect(prompt).toContain("Only framing."); expect(prompt).toContain("Label only node"); // The projection must work correctly even when nodes lack a 'propertion' field entirely. }); // ── Structural correctness of the text-formatted evidence section ─ it("evidence section contains Known Facts and Supported Inferences headers", () => { const prompt = buildSynthesisPrompt(fullScenario, []); expect(prompt).toContain("Known Facts:"); expect(prompt).toContain("Supported Inferences:"); }); it("evidence section does not contain raw graph fields (edges, resolvedNodeIds, activeUnknownNodeId, reasoningState)", () => { const prompt = buildSynthesisPrompt(fullScenario, []); // These are all control/structural graph fields that must not appear in the evidence section expect(prompt).not.toContain("edges"); expect(prompt).not.toContain("resolvedNodeIds"); expect(prompt).not.toContain("activeUnknownNodeId"); expect(prompt).not.toContain("reasoningState"); }); it("node representation includes all canonical fields — kind, label, status, value, unit", () => { const prompt = buildSynthesisPrompt(fullScenario, []); // Each node line contains: kind=..., label="...", value=... [status] expect(prompt).toContain("kind=metric"); expect(prompt).toContain('label="Complaint count increased by 35%"'); expect(prompt).toContain("[known]"); expect(prompt).toContain("[supported]"); expect(prompt).toContain("value=35 ("); expect(prompt).toContain("Primary metric baseline"); }); it("provider sees structured evidence section header", () => { const prompt = buildSynthesisPrompt(fullScenario, []); expect(prompt).toContain("Provider-Active Evidence:"); expect(prompt).not.toContain("Canonical Situation Graph"); }); // ── Edge case: empty graph (no known/supported nodes) ──── it("handles graph with no evidence nodes gracefully", () => { const prompt = buildSynthesisPrompt(emptyGraph, []); expect(prompt).not.toContain("(no evidence)"); expect(prompt).toContain("Complaints increased by 35% while production increased by 40%"); }); // ── Edge case: only supported, no known ────────────────── it("handles graph with only supported nodes", () => { const supportedOnly = { centralStatement: "Metric drift detected.", nodes: [ { id: "s1", label: "Drift exceeds threshold", description: "Supported inference", kind: "metric", status: "supported", confidence: "low" }, ], }; const prompt = buildSynthesisPrompt(supportedOnly, []); expect(prompt).toContain("Drift exceeds threshold"); }); // ── Finding integration tests ──────────────────────────── it("agreed Finding appears in synthesis with Correct Evidence label", () => { const prompt = buildSynthesisPrompt(fullScenario, [agreedFinding()]); expect(prompt).toContain("The percentage increase in complaints exceeds the threshold."); expect(prompt).toContain("Confirmed Evidence"); }); it("working premise Finding appears with Working Premises label", () => { const prompt = buildSynthesisPrompt(fullScenario, [workingFinding()]); expect(prompt).toContain("The reported percentage changes correspond to the last financial quarter."); expect(prompt).toContain("Working Premises"); }); // ── Immutability ───────────────────────────────────────── it("graph structurally unchanged after synthesis prompt build", () => { const graphSnapshot = JSON.parse(JSON.stringify(fullScenario)); buildSynthesisPrompt(fullScenario, []); expect(JSON.stringify(fullScenario)).toBe(JSON.stringify(graphSnapshot)); }); it("findings structurally unchanged after synthesis prompt build", () => { const findings = [workingFinding()]; const snapshot = JSON.parse(JSON.stringify(findings)); buildSynthesisPrompt(fullScenario, findings); expect(JSON.stringify(findings)).toBe(JSON.stringify(snapshot)); }); // ── Immutability during full seam ──────────────────────── it("graph and findings unchanged after synthesizeCurrentUnderstanding", async () => { const graphSnapshot = JSON.parse(JSON.stringify(fullScenario)); const findings = [workingFinding()]; const findingsSnapshot = JSON.parse(JSON.stringify(findings)); const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings }, { provider: fake } ); expect(JSON.stringify(fullScenario)).toBe(JSON.stringify(graphSnapshot)); expect(JSON.stringify(findings)).toBe(JSON.stringify(findingsSnapshot)); }); }); // ── Prompt authority rule verification ───────────────────── describe("buildSynthesisPrompt — prompt semantics", () => { it("prompt contains evidence-authority boundary rule (not open questions)", () => { const prompt = buildSynthesisPrompt(fullScenario, []); expect(prompt).toContain("Current Understanding describes only established or supported understanding"); expect(prompt).toContain("Do not introduce or describe open questions, unresolved uncertainties, assumptions, provisional hypotheses"); }); it("prompt clarifies centralStatement is framing context only", () => { const prompt = buildSynthesisPrompt(fullScenario, []); expect(prompt).toContain("Framing"); // Must explicitly say the centralStatement is not independent evidence expect(prompt).toContain("framing context, not independent evidence"); }); it("prompt does NOT include edges in the evidence section", () => { const prompt = buildSynthesisPrompt(fullScenario, []); // Edges should not be mentioned in the structured evidence projection expect(prompt).not.toContain("Edge"); expect(prompt).not.toMatch(/relationship:.*compares_with/); }); }); // ── Output validation tests (unchanged) ──────────────────── describe("validateSynthesisResponse", () => { it("accepts valid narrative JSON object", () => { const result = validateSynthesisResponse({ currentUnderstanding: "The data shows X" }); expect(result.valid).toBe(true); expect(result.data.currentUnderstanding).toBe("The data shows X"); }); it("accepts valid narrative JSON string", () => { const result = validateSynthesisResponse('{"currentUnderstanding": "parsed"}'); expect(result.valid).toBe(true); expect(result.data.currentUnderstanding).toBe("parsed"); }); it("rejects missing currentUnderstanding field", () => { const result = validateSynthesisResponse({ summary: "wrong field" }); expect(result.valid).toBe(false); }); it("rejects empty string narrative", () => { const result = validateSynthesisResponse({ currentUnderstanding: "" }); expect(result.valid).toBe(false); }); it("rejects malformed JSON string", () => { const result = validateSynthesisResponse("not json at all [[["); expect(result.valid).toBe(false); }); it("rejects null input", () => { expect(validateSynthesisResponse(null).valid).toBe(false); }); it("rejects undefined input", () => { expect(validateSynthesisResponse(undefined).valid).toBe(false); }); }); // ── Domain function tests (full seam) ────────────────────── describe("synthesizeCurrentUnderstanding — full seam", () => { // ── Evidence inclusion/exclusion at seam level ─────────── it("known node content flows to provider via synthesis prompt", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [] }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; expect(prompt).toContain("Complaint count increased by 35%"); expect(prompt).toContain("[known]"); }); it("supported node content flows to provider via synthesis prompt", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [] }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; expect(prompt).toContain("Production count increased by 40%"); expect(prompt).toContain("[supported]"); }); it("unknown nodes do NOT appear in provider-visible synthesis", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [] }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; expect(prompt).not.toContain("Baseline period denominator volume"); }); it("provisional hypothesis does NOT appear in synthesis prompt", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [] }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; expect(prompt).not.toContain("Product mix may explain"); }); it("resolved-question text does NOT appear in synthesis prompt", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [] }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; expect(prompt).not.toContain("Time period over which these percentage changes occurred"); }); it("eligible Finding appears in synthesis", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [workingFinding()] }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; expect(prompt).toContain("The reported percentage changes correspond to the last financial quarter."); }); it("control/reasoning sentinels excluded from synthesis", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [] }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; expect(prompt).not.toContain("currentSummary"); expect(prompt).not.toContain("reasoningState"); }); // ── Immutability ───────────────────────────────────────── it("graph structurally unchanged after synthesis", async () => { const graphSnapshot = JSON.parse(JSON.stringify(fullScenario)); const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [] }, { provider: fake } ); expect(JSON.stringify(fullScenario)).toBe(JSON.stringify(graphSnapshot)); }); it("findings structurally unchanged after synthesis", async () => { const findings = [workingFinding()]; const findingsSnapshot = JSON.parse(JSON.stringify(findings)); const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings }, { provider: fake } ); expect(JSON.stringify(findings)).toBe(JSON.stringify(findingsSnapshot)); }); // ── Input validation (unchanged) ──────────────────────── it("missing situationGraph → throws 400", async () => { await expect( synthesizeCurrentUnderstanding({}, { provider: makeFakeProvider() }) ).rejects.toThrow(/situationGraph is required/); }); it("non-object situationGraph → throws 400", async () => { await expect( synthesizeCurrentUnderstanding({ situationGraph: "not an object" }, { provider: makeFakeProvider() }) ).rejects.toThrow(/situationGraph is required/); }); it("findings as non-array → throws 400", async () => { await expect( synthesizeCurrentUnderstanding({ situationGraph: fullScenario, findings: "string" }, { provider: makeFakeProvider() }) ).rejects.toThrow(/findings must be an array/); }); it("provider generateReconstruction throws → statusCode 502", async () => { const fake = makeFakeProvider(async () => { throw new Error("provider down"); }); await expect( synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [] }, { provider: fake } ) ).rejects.toThrow(/Synthesis provider call failed|provider down/); }); it("no provider provided — falls through to getProvider()", async () => { await expect( synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [] }, {} ) ).rejects.toThrow(/OLLAMA_BASE_URL/); }); // ── Configured model resolution (unchanged) ───────────── it("default synthesis path resolves configured modelName", async () => { const fake = makeFakeProvider(); const savedModel = process.env.OLLAMA_MODEL; process.env.OLLAMA_MODEL = "configured-model-v0.50"; try { await synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [] }, { provider: fake } ); const receivedModel = fake.generateReconstruction.mock.calls[0][1]; expect(receivedModel).toBeDefined(); expect(receivedModel).not.toBeNull(); } finally { if (savedModel == null) { delete process.env.OLLAMA_MODEL; } else { process.env.OLLAMA_MODEL = savedModel; } } }); it("explicit modelName dependency overrides default", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [] }, { provider: fake, modelName: "test-model-v0.50" } ); expect(fake.generateReconstruction.mock.calls[0][1]).toBe("test-model-v0.50"); }); // ── Provider sees evidence projection, not raw graph ───── it("provider receives structured evidence projection (not full graph dump)", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [] }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; // Evidence section must be structured, not a raw JSON dump of the whole graph expect(prompt).toContain("Provider-Active Evidence"); expect(prompt).not.toContain("Canonical Situation Graph"); }); it("provider receives centralStatement as framing context", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [] }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; expect(prompt).toContain("Complaints increased by 35%"); expect(prompt).toContain("Framing"); }); it("provider receives centralStatement framing for no-evidence graph", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: emptyGraph, findings: [] }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; // Even with no evidence nodes, centralStatement framing flows through expect(prompt).toContain("Central Statement"); }); it("null/undefined findings → synthesis proceeds with empty evidence", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: null }, { provider: fake } ); expect(fake.generateReconstruction).toHaveBeenCalledTimes(1); }); it("fake provider returns valid narrative → { currentUnderstanding }", async () => { const fake = makeFakeProvider(); const result = await synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [workingFinding()] }, { provider: fake } ); expect(result).toEqual({ currentUnderstanding: "Synthesized output" }); }); it("fake provider returns empty narrative → rejected", async () => { const fake = makeFakeProvider(async () => JSON.stringify({ currentUnderstanding: "" })); await expect( synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [] }, { provider: fake } ) ).rejects.toThrow(/Synthesis validation failed/); }); it("fake provider returns malformed JSON → rejected", async () => { const fake = makeFakeProvider(async () => "{ not valid json"); await expect( synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [] }, { provider: fake } ) ).rejects.toThrow(/Synthesis validation failed/); }); it("fake provider returns object without currentUnderstanding → rejected", async () => { const fake = makeFakeProvider(async () => JSON.stringify({ wrongField: "value" })); await expect( synthesizeCurrentUnderstanding( { situationGraph: fullScenario, findings: [] }, { provider: fake } ) ).rejects.toThrow(/Synthesis validation failed/); }); });