import { describe, expect, it, vi } from "vitest"; import { filterEligibleFindings, buildSynthesisPrompt, synthesizeCurrentUnderstanding, validateSynthesisResponse, } from "@/lib/graph/current-understanding-synthesis.js"; // ── Fixtures ──────────────────────────────────────────────── const canonicalGraph = { centralStatement: "Revenue dropped 30% in Q2 due to supply chain disruption.", nodes: [ { id: "n1", proposition: "Q1 revenue was stable", description: "Baseline metric", status: "confirmed", confidence: 0.95 }, { id: "n2", proposition: "Supplier A failed deliveries in May", description: "Primary cause", status: "active", confidence: 0.85 }, { id: "n3", proposition: "Customer churn increased by 12%", description: "Secondary effect", status: "active", confidence: 0.7 }, ], edges: [ { from: "n2", to: "n3", type: "causal", context: "Supply failure led to customer dissatisfaction" }, { from: "n1", to: "n2", type: "temporal", context: "Preceding event in causal chain" }, ], }; const makeFinding = (overrides = {}) => ({ id: `find-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, proposition: "Supplier delays caused production halts", evaluation: "considered", userDisposition: null, sourceObservation: "obs-1", contributionId: "contrib-001", ...overrides, }); // Fake provider factory for tests — always has generateReconstruction // If defaultResponse is passed, use it; otherwise default to success. const makeFakeProvider = (defaultResponse) => ({ generateReconstruction: vi.fn(async () => { return typeof defaultResponse === "function" ? defaultResponse() : JSON.stringify({ currentUnderstanding: "Synthesized output" }); }), }); // ── Eligibility tests ─────────────────────────────────────── describe("filterEligibleFindings — eligibility contract", () => { it("includes null disposition → eligible", () => { const findings = [makeFinding({ userDisposition: null })]; const result = filterEligibleFindings(findings); expect(result).toHaveLength(1); expect(result[0].userDisposition).toBeNull(); }); it("includes agree disposition → eligible", () => { const findings = [makeFinding({ userDisposition: "agree" })]; const result = filterEligibleFindings(findings); expect(result).toHaveLength(1); expect(result[0].userDisposition).toBe("agree"); }); it("excludes not_quite disposition → ineligible", () => { const findings = [makeFinding({ userDisposition: "not_quite" })]; const result = filterEligibleFindings(findings); expect(result).toHaveLength(0); }); it("excludes not_relevant disposition → ineligible", () => { const findings = [makeFinding({ userDisposition: "not_relevant" })]; const result = filterEligibleFindings(findings); expect(result).toHaveLength(0); }); it("excludes rejected evaluation → excluded", () => { const findings = [makeFinding({ evaluation: "rejected" })]; const result = filterEligibleFindings(findings); expect(result).toHaveLength(0); }); it("mixed dispositions — only eligible pass through", () => { const findings = [ makeFinding({ userDisposition: null, id: "f1" }), makeFinding({ userDisposition: "agree", id: "f2" }), makeFinding({ userDisposition: "not_quite", id: "f3" }), makeFinding({ userDisposition: "not_relevant", id: "f4" }), makeFinding({ evaluation: "rejected", id: "f5" }), ]; const result = filterEligibleFindings(findings); expect(result).toHaveLength(2); expect(result.map((f) => f.id)).toEqual(["f1", "f2"]); }); it("null input returns empty array", () => { expect(filterEligibleFindings(null)).toEqual([]); expect(filterEligibleFindings(undefined)).toEqual([]); expect(filterEligibleFindings([])).toEqual([]); }); }); // ── Prompt content tests ──────────────────────────────────── describe("buildSynthesisPrompt — full graph input", () => { it("includes full canonical graph: nodes, edges, centralStatement (not just centralStatement)", () => { const prompt = buildSynthesisPrompt(canonicalGraph, []); expect(prompt).toContain("Canonical Situation Graph"); expect(prompt).toContain("Revenue dropped 30%"); // Verify nodes are included with content beyond centralStatement expect(prompt).toContain('Node(n1)'); expect(prompt).toContain("Q1 revenue was stable"); expect(prompt).toContain('Node(n2)'); expect(prompt).toContain("Supplier A failed deliveries in May"); // Verify edges are included expect(prompt).toContain("Edge(n2 → n3"); expect(prompt).toContain("causal"); expect(prompt).toContain("Edge(n1 → n2"); expect(prompt).toContain("temporal"); // Verify centralStatement value is present (not just the key name) expect(prompt).toContain("supply chain disruption"); }); it("includes eligible Finding propositions in prompt", () => { const findings = [makeFinding({ userDisposition: "agree" })]; const prompt = buildSynthesisPrompt(canonicalGraph, findings); expect(prompt).toContain("Supplier delays caused production halts"); }); it("excludes non-eligible Finding propositions from prompt", () => { const ineligibleFindings = [makeFinding({ userDisposition: "not_quite" })]; const eligibleFindings = filterEligibleFindings(ineligibleFindings); const prompt = buildSynthesisPrompt(canonicalGraph, eligibleFindings); expect(prompt).toContain("Eligible Findings"); // The excluded proposition must not appear because eligible findings is empty expect(eligibleFindings).toHaveLength(0); }); it("includes provisional (null disposition) findings", () => { const findings = [makeFinding({ userDisposition: null })]; const prompt = buildSynthesisPrompt(canonicalGraph, findings); expect(prompt).toContain("Provisional Findings"); expect(prompt).toContain("Supplier delays caused production halts"); }); it("includes confirmed (agree) findings", () => { const findings = [makeFinding({ userDisposition: "agree" })]; const prompt = buildSynthesisPrompt(canonicalGraph, findings); expect(prompt).toContain("Confirmed Evidence"); }); it("handles zero eligible Findings — synthesis still proceeds from graph alone", () => { const prompt = buildSynthesisPrompt(canonicalGraph, []); expect(prompt).toContain("(none)"); // Must still contain graph content expect(prompt).toContain("Canonical Situation Graph"); }); it("prompt contains fresh-synthesis instructions (not append semantics)", () => { const prompt = buildSynthesisPrompt(canonicalGraph, []); expect(prompt).toContain("FRESH synthesis"); expect(prompt).toContain("Do NOT treat any previous Current Understanding as input"); expect(prompt).toContain("Do NOT append to prior summaries"); }); it("prompt does NOT request graph mutations or Finding mutations", () => { const prompt = buildSynthesisPrompt(canonicalGraph, []); expect(prompt).not.toMatch(/change\s+selectedQuestion/i); }); }); // ── Output validation tests ───────────────────────────────── 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", () => { const result = validateSynthesisResponse(null); expect(result.valid).toBe(false); }); it("rejects undefined input", () => { const result = validateSynthesisResponse(undefined); expect(result.valid).toBe(false); }); }); // ── Domain function tests ─────────────────────────────────── describe("synthesizeCurrentUnderstanding — full seam", () => { it("null disposition finding → included in synthesis", async () => { const fake = makeFakeProvider(); const result = await synthesizeCurrentUnderstanding( { situationGraph: canonicalGraph, findings: [makeFinding({ userDisposition: null })] }, { provider: fake } ); expect(result.currentUnderstanding).toBe("Synthesized output"); expect(fake.generateReconstruction).toHaveBeenCalledTimes(1); const prompt = fake.generateReconstruction.mock.calls[0][0]; expect(prompt).toContain("Supplier delays caused production halts"); }); it("agree disposition finding → included in synthesis", async () => { const fake = makeFakeProvider(); const result = await synthesizeCurrentUnderstanding( { situationGraph: canonicalGraph, findings: [makeFinding({ userDisposition: "agree" })] }, { provider: fake } ); expect(result.currentUnderstanding).toBe("Synthesized output"); }); it("not_quite disposition finding → excluded from synthesis", async () => { const fake = makeFakeProvider(); const result = await synthesizeCurrentUnderstanding( { situationGraph: canonicalGraph, findings: [makeFinding({ userDisposition: "not_quite" })] }, { provider: fake } ); expect(result.currentUnderstanding).toBe("Synthesized output"); const prompt = fake.generateReconstruction.mock.calls[0][0]; // The not_quite proposition must NOT appear because it was excluded by eligibility expect(prompt).toContain("(none)"); expect(prompt).not.toContain("Supplier delays caused production halts"); }); it("not_relevant disposition finding → excluded from synthesis", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: canonicalGraph, findings: [makeFinding({ userDisposition: "not_relevant" })] }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; expect(prompt).toContain("(none)"); }); it("rejected evaluation → excluded from synthesis", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: canonicalGraph, findings: [makeFinding({ evaluation: "rejected" })] }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; expect(prompt).toContain("(none)"); }); it("zero eligible Findings → synthesis succeeds from graph alone", async () => { const fake = makeFakeProvider(); const result = await synthesizeCurrentUnderstanding( { situationGraph: canonicalGraph, findings: [] }, { provider: fake } ); expect(result.currentUnderstanding).toBe("Synthesized output"); const prompt = fake.generateReconstruction.mock.calls[0][0]; // Must still contain graph content expect(prompt).toContain("Canonical Situation Graph"); expect(prompt).toContain("Node(n1)"); }); it("fake provider returns valid narrative → { currentUnderstanding }", async () => { const fake = makeFakeProvider(); const result = await synthesizeCurrentUnderstanding( { situationGraph: canonicalGraph, findings: [makeFinding({ userDisposition: "agree" })] }, { 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: canonicalGraph, 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: canonicalGraph, 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: canonicalGraph, findings: [] }, { provider: fake } ) ).rejects.toThrow(/Synthesis validation failed/); }); // ── Immutability tests ──────────────────────────────────── it("graph structurally unchanged after synthesis", async () => { const graphSnapshot = JSON.parse(JSON.stringify(canonicalGraph)); const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: canonicalGraph, findings: [] }, { provider: fake } ); expect(JSON.stringify(canonicalGraph)).toBe(JSON.stringify(graphSnapshot)); }); it("findings structurally unchanged after synthesis", async () => { const findings = [makeFinding({ userDisposition: "agree" })]; const findingsSnapshot = JSON.parse(JSON.stringify(findings)); const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: canonicalGraph, findings }, { provider: fake } ); expect(JSON.stringify(findings)).toBe(JSON.stringify(findingsSnapshot)); }); // ── Input validation tests ──────────────────────────────── 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: canonicalGraph, 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: canonicalGraph, findings: [] }, { provider: fake } ) ).rejects.toThrow(/Synthesis provider call failed|provider down/); }); it("no provider provided — falls through to getProvider() which needs env vars", async () => { await expect( synthesizeCurrentUnderstanding( { situationGraph: canonicalGraph, findings: [] }, {} ) ).rejects.toThrow(/OLLAMA_BASE_URL/); }); // ── Configured model resolution ────────────────────────── it("default synthesis path resolves configured modelName — provider receives non-null", async () => { const fake = makeFakeProvider(); // Stub the configured model so test does not depend on dev-machine .env.local const savedModel = process.env.OLLAMA_MODEL; process.env.OLLAMA_MODEL = "configured-model-v0.50"; try { const result = await synthesizeCurrentUnderstanding( { situationGraph: canonicalGraph, findings: [] }, { provider: fake } ); expect(result.currentUnderstanding).toBe("Synthesized output"); // KEY ASSERTION: configured model must flow to provider const receivedModel = fake.generateReconstruction.mock.calls[0][1]; expect(receivedModel).toBeDefined(); expect(receivedModel).not.toBeNull(); expect(typeof receivedModel).toBe("string"); expect(receivedModel.length).toBeGreaterThan(0); } finally { // Restore original env value (may be undefined) if (savedModel == null) { delete process.env.OLLAMA_MODEL; } else { process.env.OLLAMA_MODEL = savedModel; } } }); it("explicit modelName dependency overrides default — provider receives injected model", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: canonicalGraph, findings: [] }, { provider: fake, modelName: "test-model-v0.50" } ); expect(fake.generateReconstruction.mock.calls[0][1]).toBe("test-model-v0.50"); }); // ── Provider sees full graph, not just centralStatement ─── it("provider receives full canonical graph content (nodes + edges + centralStatement)", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( { situationGraph: canonicalGraph, findings: [] }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; // Must contain node content (not just centralStatement) expect(prompt).toContain("Node(n1)"); expect(prompt).toContain("Q1 revenue was stable"); expect(prompt).toContain("Node(n2)"); expect(prompt).toContain("Supplier A failed deliveries in May"); // Must contain edge content expect(prompt).toContain("Edge(n2 → n3"); expect(prompt).toContain("causal"); // Must contain centralStatement value expect(prompt).toContain("Revenue dropped 30%"); // Must instruct about fresh synthesis (not previous CU) expect(prompt).toContain("Do NOT treat any previous Current Understanding as input"); }); });