From ff1119b4d5d3e6f4421c1ad7edaef40c6864d443 Mon Sep 17 00:00:00 2001 From: robbond Date: Sun, 30 Aug 2026 18:48:38 +0100 Subject: [PATCH] feat(confidence-engine): establish current understanding synthesis seam --- app/api/cases/synthesis/route.js | 68 +++ docs/current-handoff.md | 66 ++- lib/graph/current-understanding-synthesis.js | 238 ++++++++++ ...rent-understanding-synthesis-route.test.js | 186 ++++++++ .../current-understanding-synthesis.test.js | 410 ++++++++++++++++++ 5 files changed, 953 insertions(+), 15 deletions(-) create mode 100644 app/api/cases/synthesis/route.js create mode 100644 lib/graph/current-understanding-synthesis.js create mode 100644 tests/app/api/current-understanding-synthesis-route.test.js create mode 100644 tests/graph/current-understanding-synthesis.test.js diff --git a/app/api/cases/synthesis/route.js b/app/api/cases/synthesis/route.js new file mode 100644 index 0000000..fdd6dfc --- /dev/null +++ b/app/api/cases/synthesis/route.js @@ -0,0 +1,68 @@ +/** + * Dedicated Current Understanding synthesis API route. + * + * Route: POST /api/cases/synthesis + * + * Follows the thin route pattern established by cases/start and cases/update routes: + * parse request → invoke domain seam → return validated result → map failure status + * + * No synthesis business logic belongs in this file. + */ + +import { getProvider } from "@/lib/llm/provider.js"; +import { synthesizeCurrentUnderstanding } from "@/lib/graph/current-understanding-synthesis.js"; + +export async function POST(request) { + try { + const body = await request.json(); + + // ── Parse / validate input contract ─────────────────────── + if (!body || typeof body !== "object") { + return Response.json( + { success: false, stage: "request_validation", error: "Invalid request body" }, + { status: 400 } + ); + } + + const { situationGraph, findings } = body; + + if (!situationGraph) { + return Response.json( + { success: false, stage: "request_validation", error: "Missing situationGraph" }, + { status: 400 } + ); + } + + // ── Invoke domain seam with provider resolution ─────────── + const result = await synthesizeCurrentUnderstanding( + { situationGraph, findings }, + { provider: getProvider() } + ); + + return Response.json({ success: true, currentUnderstanding: result.currentUnderstanding }, { status: 200 }); + } catch (error) { + if (error instanceof SyntaxError) { + return Response.json( + { success: false, stage: "request_validation", error: "Invalid JSON request body" }, + { status: 400 } + ); + } + + if (error.statusCode) { + return Response.json( + { + success: false, + stage: error.statusCode === 400 ? "request_validation" : "provider", + error: error.message ?? "Synthesis failed", + }, + { status: error.statusCode } + ); + } + + // Unexpected error + return Response.json( + { success: false, stage: "internal", error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/docs/current-handoff.md b/docs/current-handoff.md index 113836c..b25566e 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -2761,27 +2761,63 @@ defect has been reduced to a defined reconstruction problem. Implementation of canonical Current Understanding reconstruction belongs to v0.50. -### v0.50 — NEXT BOUNDARY +## v0.50 — STANDALONE SYNTHESIS SEAM (2026-08-30) -The first bounded question for the next branch: +### Closed boundaries -> **What minimal synthesis API/helper boundary should own the dedicated `SituationGraph + eligible Findings → Current Understanding` LLM operation?** +**Standalone synthesis seam implemented.** -Do not answer that question in this task. +- **Domain function:** `lib/graph/current-understanding-synthesis.js` + - `filterEligibleFindings()` — eligibility contract: null/agree eligible; not_quite/not_relevant/rejected excluded + - `buildSynthesisPrompt()` — full SituationGraph (nodes + edges + centralStatement) + eligible Findings + - `synthesizeCurrentUnderstanding()` — DI-driven provider call, narrative-only output + - `validateSynthesisResponse()` / `synthesisResponseSchema` — zod schema for `{ currentUnderstanding }` -Subsequent v0.50 work will need to resolve: +- **API route:** `app/api/cases/synthesis/route.js` — `POST /api/cases/synthesis`, thin parse → invoke → return pattern -1. synthesis API/helper ownership -2. deterministic input normalization / eligible-Finding selection -3. synthesis prompt/schema contract -4. completed-transition integration points -5. removal/replacement of append semantics -6. retirement of Done-for-now CU promotion -7. reload/cache freshness strategy -8. targeted deterministic + live behavioural verification +- **Domain seam ownership:** + - ScenarioForm → WHEN synthesis occurs (NOT wired; untouched in this increment) + - This module → HOW canonical state becomes narrative + - Provider → generation via DI (default provider + test injection pattern) -This is a roadmap, not permission to implement all of it at once. -v0.50 must continue using bounded increments. +- **Eligibility filtering inside domain seam:** + - `null` → eligible (accepted-by-default, provisional working interpretation) + - `agree` → eligible (confirmed evidence) + - `not_quite` → excluded until corrected proposition saved + - `not_relevant` → excluded (discounted from active reasoning) + - `evaluation: "rejected"` → excluded + +- **Full SituationGraph + canonical Findings input** — not just centralStatement; nodes and edges included + +- **narrative-only output** — `{ currentUnderstanding }` string; no graph/Finding mutation + +- **52 targeted tests passing:** + - `tests/graph/current-understanding-synthesis.test.js` — 40/40 + - `tests/app/api/current-understanding-synthesis-route.test.js` — 12/12 + +- **Build result:** clean production build + +- **No ScenarioForm trigger integration yet** — wire-in in a future increment. + +### Eligibility contract verification (all PASS) + +| Scenario | Covered | Result | +|----------|---------|--------| +| null included | Yes — graph test line 47 + synthesis test line 215 | ✅ PASS | +| agree included | Yes — graph test line 54 + synthesis test line 227 | ✅ PASS | +| not_quite excluded | Yes — graph test line 61 + synthesis test line 236 | ✅ PASS | +| not_relevant excluded | Yes — graph test line 67 + synthesis test line 249 | ✅ PASS | +| rejected/invalid excluded | Yes — graph test line 73 + synthesis test line 259 | ✅ PASS | +| zero eligible Findings supported | Yes — graph test line 151/269 + mixed dispositions test | ✅ PASS | + +### No changes to + +- `components/scenario-form.jsx` +- `components/reasoning-workspace.jsx` +- `app/api/cases/update/route.js` +- Done-for-now code +- storage +- focused workspace --- diff --git a/lib/graph/current-understanding-synthesis.js b/lib/graph/current-understanding-synthesis.js new file mode 100644 index 0000000..fe11b05 --- /dev/null +++ b/lib/graph/current-understanding-synthesis.js @@ -0,0 +1,238 @@ +/** + * Current Understanding synthesis seam — standalone domain function. + * + * Accepts: SituationGraph + all canonical Findings + * Outputs: narrative-only { currentUnderstanding } + * + * Ownership: + * ScenarioForm → WHEN synthesis occurs (untouched in this increment) + * This module → HOW canonical state becomes narrative + * Provider → generation (via dependency injection) + */ + +import { z } from "zod"; +import { getProvider } from "../llm/provider.js"; + +// ── Eligibility normalization ────────────────────────────── + +/** + * Filter findings to only eligible ones according to disposition contract: + * null → eligible (accepted-by-default, provisional working interpretation) + * "agree" → eligible (confirmed evidence) + * "not_quite" → ineligible until corrected proposition is saved + * "not_relevant" → ineligible (discounted from active reasoning) + * rejected → excluded (already failed structural validation) + * + * Also excludes any Finding that has evaluation === "rejected". + */ +export function filterEligibleFindings(findings) { + if (!findings || !Array.isArray(findings)) return []; + + return findings.filter((f) => { + // Structural validation exclusion (already evaluated upstream) + if (f.evaluation === "rejected") return false; + + const disposition = f.userDisposition; + + // not_relevant → ineligible + if (disposition === "not_relevant") return false; + + // not_quite → ineligible until corrected proposition is saved + if (disposition === "not_quite") return false; + + // null, agree → eligible; anything else unexpected but let through + return true; + }); +} + +// ── Synthesis prompt construction ────────────────────────── + +/** + * Build the synthesis prompt from SituationGraph and eligible Findings. + * The prompt instructs the model to produce one coherent Current Understanding narrative + * from the provided inputs, without append semantics. + */ +export function buildSynthesisPrompt(situationGraph, findings) { + // Build structured graph representation for the prompt + const graphInfo = { + centralStatement: situationGraph.centralStatement ?? "", + nodes: (situationGraph.nodes ?? []).map((n) => ({ + id: n.id, + proposition: n.proposition ?? "", + description: n.description ?? "", + status: n.status ?? null, + confidence: n.confidence ?? null, + })), + edges: (situationGraph.edges ?? []).map((e) => ({ + from: e.from ?? null, + to: e.to ?? null, + type: e.type ?? "", + context: e.context ?? "", + })), + }; + + // Build a structured representation of eligible Findings for the prompt + const findingsSections = []; + + if (findings.length > 0) { + const agreed = findings.filter((f) => f.userDisposition === "agree"); + const provisional = findings.filter( + (f) => f.userDisposition === null + ); + + if (agreed.length > 0) { + findingsSections.push({ + label: "Confirmed Evidence", + items: agreed.map((f) => ({ + proposition: f.proposition, + id: f.id ?? null, + })), + }); + } + + if (provisional.length > 0) { + findingsSections.push({ + label: "Provisional Findings (working interpretation)", + items: provisional.map((f) => ({ + proposition: f.proposition, + id: f.id ?? null, + })), + }); + } + } + + // Human-readable node and edge representations for the prompt + const nodesSection = graphInfo.nodes.length > 0 ? `\nNodes:\n${graphInfo.nodes.map((n) => ` Node(${n.id}): ${n.proposition}${n.description ? ` — ${n.description}` : ""}${n.status ? ` [${n.status}]` : ""}`).join("\n")}` : ""; + const edgesSection = graphInfo.edges.length > 0 ? `\nEdges:\n${graphInfo.edges.map((e) => ` Edge(${e.from} → ${e.to}, type=${e.type}): ${e.context || "(no context)"}`).join("\n")}` : ""; + + const prompt = `You are producing a Current Understanding narrative from investigation evidence. + +Canonical Situation Graph: +${JSON.stringify(graphInfo, null, 2)}${nodesSection}${edgesSection} + +Eligible Findings: +${findingsSections.length > 0 + ? JSON.stringify(findingsSections, null, 2) + : "(none)"} + +Rules for this synthesis: +1. Produce exactly ONE coherent narrative paragraph (or short multi-sentence paragraph) that represents the Current Understanding of the situation. +2. Synthesize all provided evidence into a unified understanding — do not list or append findings. The result should read as a natural summary, not a bullet list. +3. This is a FRESH synthesis from the complete set of inputs above. Do NOT treat any previous Current Understanding as input or authority. Do NOT append to prior summaries. +4. Use only information present in the Situation context and Eligible Findings above. +5. If no eligible Findings are provided, synthesize from the Situation context alone. +6. Return ONLY a JSON object with this exact structure: + {"currentUnderstanding": "your narrative here"} +7. The currentUnderstanding value must be a non-empty string. + +Return ONLY the JSON object. No markdown, no explanation, no preamble.`; + + return prompt; +} + +// ── Synthesis response schema ────────────────────────────── + +export const synthesisResponseSchema = z.object({ + currentUnderstanding: z + .string() + .min(1, "currentUnderstanding must be a non-empty string"), +}); + +/** Validate raw provider output against synthesis response schema */ +export function validateSynthesisResponse(raw) { + if (raw == null) { + return { valid: false, error: "Provider returned null/undefined" }; + } + + let parsed; + if (typeof raw === "string") { + try { + parsed = JSON.parse(raw); + } catch { + return { valid: false, error: "Provider output is not valid JSON" }; + } + } else if (typeof raw === "object") { + parsed = raw; + } else { + return { valid: false, error: "Provider output has unexpected type" }; + } + + const result = synthesisResponseSchema.safeParse(parsed); + if (!result.success) { + const firstIssue = result.error.issues[0]; + return { + valid: false, + error: firstIssue?.message ?? "Invalid synthesis response", + }; + } + + return { valid: true, data: result.data }; +} + +// ── Main domain function ─────────────────────────────────── + +/** + * Standalone Current Understanding synthesis. + * + * @param {{ situationGraph: object, findings: Array }} inputs + * - situationGraph: the authoritative SituationGraph object + * - findings: all canonical Findings (may be empty array) + * @param {{ provider?: object }} [dependencies={}] + * - provider: dependency-injected provider with generateReconstruction(prompt, modelName) + * @returns {Promise<{ currentUnderstanding: string }>} validated narrative-only result + */ +export async function synthesizeCurrentUnderstanding( + { situationGraph, findings }, + dependencies = {} +) { + // 1. Input validation + if (!situationGraph || typeof situationGraph !== "object") { + const err = new Error("Invalid input: situationGraph is required and must be an object"); + err.statusCode = 400; + throw err; + } + + if (findings != null && !Array.isArray(findings)) { + const err = new Error("Invalid input: findings must be an array or null/undefined"); + err.statusCode = 400; + throw err; + } + + // Normalize empty findings to empty array + const allFindings = findings ?? []; + + // 2. Eligibility normalization (domain seam responsibility) + const eligibleFindings = filterEligibleFindings(allFindings); + + // 3. Build synthesis prompt (uses full canonical graph, not just centralStatement) + const prompt = buildSynthesisPrompt(situationGraph, eligibleFindings); + + // 4. Resolve provider — DI fallback to configured default + const provider = dependencies.provider ?? getProvider(); + if (!provider || typeof provider.generateReconstruction !== "function") { + throw new Error("Invalid dependency: provider must have generateReconstruction"); + } + + let rawResponse; + try { + rawResponse = await provider.generateReconstruction( + prompt, + dependencies.modelName ?? null + ); + } catch (error) { + const err = new Error(error.message ?? "Synthesis provider call failed"); + err.statusCode = 502; + throw err; + } + + // 5. Validate response + const validated = validateSynthesisResponse(rawResponse); + if (!validated.valid) { + const err = new Error(`Synthesis validation failed: ${validated.error}`); + err.statusCode = 502; + throw err; + } + + // 6. Return narrative-only result — no graph/Finding mutation + return { currentUnderstanding: validated.data.currentUnderstanding }; +} diff --git a/tests/app/api/current-understanding-synthesis-route.test.js b/tests/app/api/current-understanding-synthesis-route.test.js new file mode 100644 index 0000000..6ff9c53 --- /dev/null +++ b/tests/app/api/current-understanding-synthesis-route.test.js @@ -0,0 +1,186 @@ +import { describe, expect, it, vi } from "vitest"; + +// ── Mock domain seam and provider at module level ─────────── + +const mockSynthesize = vi.fn(); + +vi.mock("@/lib/graph/current-understanding-synthesis.js", () => ({ + synthesizeCurrentUnderstanding: (...args) => mockSynthesize(...args), +})); + +vi.mock("@/lib/llm/provider.js", () => ({ + getProvider: () => ({}), +})); + +// ── Helpers ───────────────────────────────────────────────── + +function makeValidGraph() { + return { + centralStatement: "Test situation", + nodes: [{ id: "n1", proposition: "Node prop" }], + edges: [], + }; +} + +function makeRequest(body) { + return new Request("http://localhost/api/cases/synthesis", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +// ── Route tests — valid POST ──────────────────────────────── + +describe("POST /api/cases/synthesis — valid request", () => { + beforeEach(() => mockSynthesize.mockClear()); + + it("invokes synthesis domain seam with situationGraph + findings", async () => { + mockSynthesize.mockResolvedValue({ currentUnderstanding: "Synthesized result" }); + const { POST } = await import("@/app/api/cases/synthesis/route.js"); + const res = await POST(makeRequest({ situationGraph: makeValidGraph(), findings: [] })); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(data.currentUnderstanding).toBe("Synthesized result"); + expect(mockSynthesize).toHaveBeenCalledTimes(1); + }); + + it("returns narrative result on success", async () => { + mockSynthesize.mockResolvedValue({ currentUnderstanding: "The revenue dropped because of X and Y." }); + const { POST } = await import("@/app/api/cases/synthesis/route.js"); + const res = await POST(makeRequest({ situationGraph: makeValidGraph() })); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + expect(typeof data.currentUnderstanding).toBe("string"); + expect(data.currentUnderstanding.length).toBeGreaterThan(0); + }); + + it("passes findings to domain seam for eligibility filtering", async () => { + mockSynthesize.mockResolvedValue({ currentUnderstanding: "OK" }); + const findings = [ + { id: "f1", proposition: "agree finding", userDisposition: "agree", evaluation: "considered" }, + { id: "f2", proposition: "null finding", userDisposition: null, evaluation: "considered" }, + { id: "f3", proposition: "not_quite finding", userDisposition: "not_quite", evaluation: "considered" }, + ]; + + const { POST } = await import("@/app/api/cases/synthesis/route.js"); + await POST(makeRequest({ situationGraph: makeValidGraph(), findings })); + + expect(mockSynthesize).toHaveBeenCalledTimes(1); + expect(mockSynthesize.mock.calls[0][0].findings).toHaveLength(3); + }); +}); + +// ── Route tests — error handling ──────────────────────────── + +describe("POST /api/cases/synthesis — error cases", () => { + it("missing situationGraph → 400", async () => { + const { POST } = await import("@/app/api/cases/synthesis/route.js"); + const res = await POST(makeRequest({ findings: [] })); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.stage).toBe("request_validation"); + }); + + it("invalid JSON body → 400", async () => { + const req = new Request("http://localhost/api/cases/synthesis", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not json", + }); + const { POST } = await import("@/app/api/cases/synthesis/route.js"); + const res = await POST(req); + + expect(res.status).toBe(400); + }); + + it("null body → 400", async () => { + const { POST } = await import("@/app/api/cases/synthesis/route.js"); + const res = await POST(makeRequest(null)); + + expect(res.status).toBe(400); + }); + + it("domain seam throws with statusCode → mapped status", async () => { + mockSynthesize.mockRejectedValue(new Error("Provider failed")); + // Add statusCode property to the error object after creation + const err = Object.assign(new Error("Provider failed"), { statusCode: 502 }); + mockSynthesize.mockRejectedValue(err); + + const { POST } = await import("@/app/api/cases/synthesis/route.js"); + const res = await POST(makeRequest({ situationGraph: makeValidGraph() })); + + expect(res.status).toBe(502); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.stage).toBe("provider"); + }); + + it("domain seam throws without statusCode → 500", async () => { + mockSynthesize.mockRejectedValue(new Error("unknown error")); + + const { POST } = await import("@/app/api/cases/synthesis/route.js"); + const res = await POST(makeRequest({ situationGraph: makeValidGraph() })); + + expect(res.status).toBe(500); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.stage).toBe("internal"); + }); + + it("domain seam throws 400 → mapped to 400", async () => { + const err = Object.assign(new Error("Invalid input"), { statusCode: 400 }); + mockSynthesize.mockRejectedValue(err); + + const { POST } = await import("@/app/api/cases/synthesis/route.js"); + const res = await POST(makeRequest({ situationGraph: makeValidGraph() })); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.success).toBe(false); + expect(data.stage).toBe("request_validation"); + }); +}); + +// ── Route thinness — no business logic in route ───────────── + +describe("Route thinness", () => { + beforeEach(() => mockSynthesize.mockClear()); + + it("route does not filter eligibility itself (domain seam owns it)", async () => { + mockSynthesize.mockResolvedValue({ currentUnderstanding: "OK" }); + const findings = [ + { id: "f1", proposition: "ineligible", userDisposition: "not_quite", evaluation: "considered" }, + ]; + + const { POST } = await import("@/app/api/cases/synthesis/route.js"); + await POST(makeRequest({ situationGraph: makeValidGraph(), findings })); + + // Route passes all findings through — domain seam filters + expect(mockSynthesize.mock.calls[0][0].findings).toHaveLength(1); + }); + + it("route does not construct prompts", async () => { + mockSynthesize.mockResolvedValue({ currentUnderstanding: "OK" }); + + const { POST } = await import("@/app/api/cases/synthesis/route.js"); + await POST(makeRequest({ situationGraph: makeValidGraph() })); + + expect(mockSynthesize).toHaveBeenCalledTimes(1); + }); + + it("route does not contain provider logic (delegates to domain seam)", async () => { + mockSynthesize.mockResolvedValue({ currentUnderstanding: "OK" }); + + const { POST } = await import("@/app/api/cases/synthesis/route.js"); + await POST(makeRequest({ situationGraph: makeValidGraph() })); + + expect(mockSynthesize).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/graph/current-understanding-synthesis.test.js b/tests/graph/current-understanding-synthesis.test.js new file mode 100644 index 0000000..85db615 --- /dev/null +++ b/tests/graph/current-understanding-synthesis.test.js @@ -0,0 +1,410 @@ +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/); + }); + + // ── 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"); + }); +});