Files
confidence-engine/tests/graph/current-understanding-synthesis.test.js

639 lines
27 KiB
JavaScript

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 () => {
const response = typeof defaultResponse === "function"
? await defaultResponse()
: JSON.stringify({ currentUnderstanding: "Synthesized output" });
return { response, providerApiPath: "/test-provider" };
}),
});
// ── 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]");
expect(fake.generateReconstruction.mock.calls[0][2]).toEqual({
type: "object",
properties: { currentUnderstanding: { type: "string" } },
required: ["currentUnderstanding"],
additionalProperties: false,
});
});
it("unwraps the provider response before validating the synthesis narrative", async () => {
const fake = makeFakeProvider(() => ({ currentUnderstanding: "Wrapped narrative" }));
await expect(synthesizeCurrentUnderstanding(
{ situationGraph: fullScenario, findings: [] },
{ provider: fake },
)).resolves.toEqual({ currentUnderstanding: "Wrapped narrative" });
});
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/);
});
});