feat(confidence-engine): establish current understanding synthesis seam

This commit is contained in:
2026-08-30 18:48:38 +01:00
parent 00ba343ed9
commit ff1119b4d5
5 changed files with 953 additions and 15 deletions
+68
View File
@@ -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 }
);
}
}
+51 -15
View File
@@ -2761,27 +2761,63 @@ defect has been reduced to a defined reconstruction problem.
Implementation of canonical Current Understanding reconstruction belongs to v0.50. 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 - **Domain seam ownership:**
2. deterministic input normalization / eligible-Finding selection - ScenarioForm → WHEN synthesis occurs (NOT wired; untouched in this increment)
3. synthesis prompt/schema contract - This module → HOW canonical state becomes narrative
4. completed-transition integration points - Provider → generation via DI (default provider + test injection pattern)
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
This is a roadmap, not permission to implement all of it at once. - **Eligibility filtering inside domain seam:**
v0.50 must continue using bounded increments. - `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
--- ---
@@ -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<object> }} 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 };
}
@@ -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);
});
});
@@ -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");
});
});