Files
confidence-engine/tests/graph/investigation-overview-synthesis.test.js
T

463 lines
20 KiB
JavaScript

import { describe, expect, it, vi } from "vitest";
import {
filterEligibleFindings,
buildOverviewSynthesisPrompt,
synthesizeInvestigationOverview,
validateOverviewResponse,
} from "@/lib/graph/investigation-overview-synthesis.js";
// ── Fixtures ────────────────────────────────────────────────
const graph = {
centralStatement: "Revenue declined while churn increased.",
nodes: [
{ id: "n1", label: "Revenue down 12%", description: "Primary metric baseline", kind: "metric", status: "known", confidence: "high", value: 12, unit: "%" },
{ id: "n2", label: "Customer churn up 8%", description: "Secondary metric baseline", kind: "metric", status: "supported", confidence: "medium", value: 8, unit: "%" },
{ id: "n3", label: "Open question about baseline period", description: "Unknown", kind: "unknown", status: "unknown", confidence: "low" },
{ id: "n4", label: "Resolved time period question", description: "Resolved answer", kind: "question", status: "resolved", confidence: "high" },
{ id: "n5", label: "Provisional hypothesis about market conditions", description: "Not yet tested", kind: "hypothesis", status: "provisional", confidence: "low" },
],
edges: [{ fromNodeId: "n1", toNodeId: "n2", relationship: "correlates_with" }],
activeUnknownNodeId: "n3",
resolvedNodeIds: ["n4"],
currentSummary: "Sentinel: must not appear",
};
const eligibleFindings = [
{ id: "f1", proposition: "Revenue decline matches sector trend.", userDisposition: "agree" },
{ id: "f2", proposition: "Churn increase correlates with pricing change date.", userDisposition: null },
{ id: "f3", proposition: "This finding is not relevant.", userDisposition: "not_relevant" },
{ id: "f4", proposition: "Rejected finding.", evaluation: "rejected", userDisposition: null },
];
const plausibleInterps = [
{ id: "p1", description: "Market-wide downturn could explain revenue decline.", confidence: "medium", supportingEvidenceIds: ["n1"] },
{ id: "p2", description: "Product feature regression might cause churn increase.", confidence: "low", supportingEvidenceIds: ["n2"] },
];
const fakeProvider = (defaultResponse) => ({
generateReconstruction: vi.fn(() => {
return typeof defaultResponse === "function"
? defaultResponse()
: JSON.stringify({
understanding: "Synthesized understanding from evidence.",
plausibleInterpretations: "Remaining plausible explanations.",
});
}),
});
// ── 1. Supported evidence is available to the understanding synthesis ───────────────
describe("investigation overview — evidence availability", () => {
it("known node content flows to understanding section input", () => {
const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps);
expect(prompt).toContain("Revenue down 12%");
expect(prompt).toContain("[known]");
expect(prompt).toContain("Known Facts:");
});
it("supported node content flows to understanding section input", () => {
const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps);
expect(prompt).toContain("Customer churn up 8%");
expect(prompt).toContain("[supported]");
expect(prompt).toContain("Supported Inferences:");
});
it("eligible agreed Finding flows to understanding section input", () => {
const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps);
expect(prompt).toContain("Revenue decline matches sector trend.");
expect(prompt).toContain("Confirmed Evidence");
});
it("eligible working premise Finding flows to understanding section input", () => {
const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps);
expect(prompt).toContain("Churn increase correlates with pricing change date.");
expect(prompt).toContain("Working Premises");
});
it("plausible interpretations flow to separate section input", () => {
const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps);
expect(prompt).toContain("Market-wide downturn could explain revenue decline.");
expect(prompt).toContain("Plausible Interpretations:");
expect(prompt).toContain("SECTION B INPUTS");
});
it("centralStatement flows as framing context", () => {
const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps);
expect(prompt).toContain("Revenue declined while churn increased.");
});
});
// ── 2. Unresolved graph material is excluded ───────────────────────
describe("investigation overview — unresolved material excluded", () => {
it("unknown nodes do NOT appear in the overview prompt", () => {
const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps);
expect(prompt).not.toContain("Open question about baseline period");
expect(prompt).not.toContain('status":"unknown"');
});
it("provisional nodes do NOT appear in the overview prompt", () => {
const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps);
expect(prompt).not.toContain("Provisional hypothesis about market conditions");
expect(prompt).not.toContain('status":"provisional"');
});
it("resolved node text does NOT appear in the overview prompt", () => {
const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps);
expect(prompt).not.toContain("Resolved time period question");
expect(prompt).not.toContain('status":"resolved"');
});
it("control fields excluded from overview prompt", () => {
const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps);
expect(prompt).not.toContain("currentSummary");
expect(prompt).not.toContain("Sentinel: must not appear");
expect(prompt).not.toContain("reasoningState");
expect(prompt).not.toContain("activeUnknownNodeId");
expect(prompt).not.toContain("resolvedNodeIds");
});
it("edges do NOT appear in the overview prompt", () => {
const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps);
expect(prompt).not.toContain("correlates_with");
});
it("ineligible findings (not_relevant + rejected) excluded from evidence input", () => {
const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps);
expect(prompt).not.toContain("This finding is not relevant.");
expect(prompt).not.toContain("Rejected finding.");
});
});
// ── 3. Plausible interpretations remain separately represented ───────────────
describe("investigation overview — interpretation separation", () => {
it("plausible interpretations appear only in SECTION B, not SECTION A", () => {
const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps);
// They should appear under Plausible Interpretations (section B)
expect(prompt).toContain("Plausible Interpretations:");
// And also appear once in the document (in section B only)
const lines = prompt.split("\n");
let foundInSectionA = false;
let foundInSectionB = false;
let currentSection = null;
for (const line of lines) {
if (line.includes("SECTION A")) currentSection = "a";
if (line.includes("SECTION B")) currentSection = "b";
if (currentSection === "a" && line.includes("Product feature regression")) foundInSectionA = true;
if (currentSection === "b" && line.includes("Product feature regression")) foundInSectionB = true;
}
expect(foundInSectionA).toBe(false);
expect(foundInSectionB).toBe(true);
});
it("empty plausible interpretations handled gracefully", () => {
const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, []);
expect(prompt).toContain("SECTION B INPUTS");
// When empty, "(none)" indicates no interpretations — acceptable behavior
expect(prompt).toContain("SECTION B INPUTS — Plausible Interpretations");
});
});
// ── 4. Plausible interpretations NOT inserted into evidence-backed projection ───────────────
describe("investigation overview — epistemic boundary integrity", () => {
it("plausible interpretations are never included in the known+supported node projection", () => {
// Create a graph without any plausible interpretation nodes (which would be status "provisional")
const cleanGraph = {
centralStatement: "Test scenario.",
nodes: [
{ id: "a1", label: "Confirmed fact", description: "Evidence", kind: "metric", status: "known" },
{ id: "a2", label: "Supported inference", description: "Evidence", kind: "metric", status: "supported" },
],
};
// Even when plausibleInterps are passed, they must not enter the evidence section
const prompt = buildOverviewSynthesisPrompt(cleanGraph, [], [{ id: "x1", description: "A plausible interp with its own id and description.", confidence: "low" }]);
// SECTION A has only a1/a2 content — no interpretation leakage
expect(prompt).toContain("Confirmed fact");
expect(prompt).toContain("[known]");
});
it("passing plausibleInterpretations with no graph evidence still produces valid prompt", () => {
const minimalGraph = { centralStatement: "Minimal." };
const prompt = buildOverviewSynthesisPrompt(minimalGraph, [], plausibleInterps);
expect(prompt).toContain("SECTION A INPUTS");
expect(prompt).toContain("SECTION B INPUTS");
expect(prompt).not.toContain("[known]");
expect(prompt).not.toContain("[supported]");
});
it("prompt contains explicit epistemic boundary rules", () => {
const prompt = buildOverviewSynthesisPrompt(graph, eligibleFindings, plausibleInterps);
expect(prompt).toContain("Section A (understanding) MUST contain only established or supported understanding");
expect(prompt).toContain("never promoted into Section A");
expect(prompt).toContain("Do NOT introduce any new facts");
// SECTION B must also be present for interpretation content
expect(prompt).toContain("SECTION B INPUTS");
});
});
// ── 5. Output contract — structurally distinct fields ───────────────
describe("investigation overview — output validation contract", () => {
it("valid two-field response accepted", () => {
const result = validateOverviewResponse({
understanding: "We understand X.",
plausibleInterpretations: "Y remains plausible.",
});
expect(result.valid).toBe(true);
expect(result.data.understanding).toBe("We understand X.");
expect(result.data.plausibleInterpretations).toBe("Y remains plausible.");
});
it("valid JSON string response accepted", () => {
const result = validateOverviewResponse(JSON.stringify({ understanding: "parsed", plausibleInterpretations: "also parsed" }));
expect(result.valid).toBe(true);
});
it("missing understanding field rejected", () => {
const result = validateOverviewResponse({ plausibleInterpretations: "Only one field" });
expect(result.valid).toBe(false);
});
it("missing plausibleInterpretations field rejected", () => {
const result = validateOverviewResponse({ understanding: "Only one field" });
expect(result.valid).toBe(false);
});
it("empty string understanding rejected", () => {
const result = validateOverviewResponse({ understanding: "", plausibleInterpretations: "something" });
expect(result.valid).toBe(false);
});
it("empty string plausibleInterpretations rejected", () => {
const result = validateOverviewResponse({ understanding: "something", plausibleInterpretations: "" });
expect(result.valid).toBe(false);
});
it("malformed JSON rejected", () => {
const result = validateOverviewResponse("not json [[[");
expect(result.valid).toBe(false);
});
it("null input rejected", () => {
expect(validateOverviewResponse(null).valid).toBe(false);
});
it("undefined input rejected", () => {
expect(validateOverviewResponse(undefined).valid).toBe(false);
});
it("non-object input rejected", () => {
expect(validateOverviewResponse("just a string").valid).toBe(false);
});
});
// ── 6. No decision/recommendation/readiness fields allowed ───────────────
describe("investigation overview — no forbidden epistemic leakage", () => {
it("recommendation field rejected by validator", () => {
const result = validateOverviewResponse({
understanding: "We understand X.",
plausibleInterpretations: "Y is plausible.",
recommendation: "Do Y next.",
});
expect(result.valid).toBe(false);
expect(result.reason).toContain("forbidden_field");
});
it("decision field rejected by validator", () => {
const result = validateOverviewResponse({
understanding: "We understand X.",
plausibleInterpretations: "Y is plausible.",
decision: "Proceed with Y.",
});
expect(result.valid).toBe(false);
});
it("confidenceScore field rejected by validator", () => {
const result = validateOverviewResponse({
understanding: "We understand X.",
plausibleInterpretations: "Y is plausible.",
confidenceScore: 0.8,
});
expect(result.valid).toBe(false);
});
it("nextAction field rejected by validator", () => {
const result = validateOverviewResponse({
understanding: "We understand X.",
plausibleInterpretations: "Y is plausible.",
nextAction: "Investigate Y.",
});
expect(result.valid).toBe(false);
});
it("priority field rejected by validator", () => {
const result = validateOverviewResponse({
understanding: "We understand X.",
plausibleInterpretations: "Y is plausible.",
priority: "high",
});
expect(result.valid).toBe(false);
});
it("readiness field rejected by validator", () => {
const result = validateOverviewResponse({
understanding: "We understand X.",
plausibleInterpretations: "Y is plausible.",
readiness: "ready",
});
expect(result.valid).toBe(false);
});
});
// ── Domain function seam tests (full overview synthesis) ───────────────
describe("synthesizeInvestigationOverview — full seam", () => {
it("produces structured overview output from valid inputs", async () => {
const fake = fakeProvider();
const result = await synthesizeInvestigationOverview(
{ situationGraph: graph, findings: eligibleFindings, plausibleInterpretations: plausibleInterps },
{ provider: fake }
);
expect(result).toHaveProperty("understanding");
expect(result).toHaveProperty("plausibleInterpretations");
expect(typeof result.understanding).toBe("string");
expect(typeof result.plausibleInterpretations).toBe("string");
expect(fake.generateReconstruction).toHaveBeenCalledTimes(1);
});
it("provider receives prompt with evidence-authority boundary rules", async () => {
const fake = fakeProvider();
await synthesizeInvestigationOverview(
{ situationGraph: graph, findings: eligibleFindings, plausibleInterpretations: plausibleInterps },
{ provider: fake }
);
const prompt = fake.generateReconstruction.mock.calls[0][0];
expect(prompt).toContain("EPISTEMIC BOUNDARY RULES");
expect(prompt).toContain("SECTION A INPUTS");
expect(prompt).toContain("SECTION B INPUTS");
expect(prompt).toContain("never promoted into Section A");
});
it("unknown nodes do NOT appear in provider-visible prompt", async () => {
const fake = fakeProvider();
await synthesizeInvestigationOverview(
{ situationGraph: graph, findings: eligibleFindings, plausibleInterpretations: plausibleInterps },
{ provider: fake }
);
const prompt = fake.generateReconstruction.mock.calls[0][0];
expect(prompt).not.toContain("Open question about baseline period");
});
it("provisional nodes do NOT appear in provider-visible prompt", async () => {
const fake = fakeProvider();
await synthesizeInvestigationOverview(
{ situationGraph: graph, findings: eligibleFindings, plausibleInterpretations: plausibleInterps },
{ provider: fake }
);
const prompt = fake.generateReconstruction.mock.calls[0][0];
expect(prompt).not.toContain("Provisional hypothesis");
});
it("plausible interpretations remain in SECTION B, not SECTION A", async () => {
const fake = fakeProvider();
await synthesizeInvestigationOverview(
{ situationGraph: graph, findings: eligibleFindings, plausibleInterpretations: plausibleInterps },
{ provider: fake }
);
const prompt = fake.generateReconstruction.mock.calls[0][0];
// Should only appear under SECTION B
expect(prompt).toContain("Plausible Interpretations:");
});
it("missing situationGraph throws", async () => {
await expect(
synthesizeInvestigationOverview({}, { provider: fakeProvider() })
).rejects.toThrow(/situationGraph is required/);
});
it("non-object situationGraph throws", async () => {
await expect(
synthesizeInvestigationOverview({ situationGraph: "not an object" }, { provider: fakeProvider() })
).rejects.toThrow(/situationGraph is required/);
});
it("findings as non-array throws", async () => {
await expect(
synthesizeInvestigationOverview({ situationGraph: graph, findings: "string" }, { provider: fakeProvider() })
).rejects.toThrow(/findings must be an array/);
});
it("plausibleInterpretations as non-array throws", async () => {
await expect(
synthesizeInvestigationOverview({ situationGraph: graph, plausibleInterpretations: "string" }, { provider: fakeProvider() })
).rejects.toThrow(/plausibleInterpretations must be an array/);
});
it("no provider throws", async () => {
await expect(
synthesizeInvestigationOverview({ situationGraph: graph, findings: [], plausibleInterpretations: [] }, {})
).rejects.toThrow(/OLLAMA_BASE_URL/);
});
it("invalid model response throws validation error", async () => {
const fake = fakeProvider(async () => JSON.stringify({ wrongField: "value" }));
await expect(
synthesizeInvestigationOverview(
{ situationGraph: graph, findings: [], plausibleInterpretations: [] },
{ provider: fake }
)
).rejects.toThrow(/validation failed/);
});
it("provider throws → propagation", async () => {
const fake = fakeProvider(async () => { throw new Error("down"); });
await expect(
synthesizeInvestigationOverview(
{ situationGraph: graph, findings: [], plausibleInterpretations: [] },
{ provider: fake }
)
).rejects.toThrow(/provider call failed/);
});
it("graph and findings immutable after synthesis", async () => {
const graphSnapshot = JSON.parse(JSON.stringify(graph));
const findingsSnapshot = JSON.parse(JSON.stringify(eligibleFindings));
const fake = fakeProvider();
await synthesizeInvestigationOverview(
{ situationGraph: graph, findings: eligibleFindings, plausibleInterpretations: plausibleInterps },
{ provider: fake }
);
expect(JSON.stringify(graph)).toBe(JSON.stringify(graphSnapshot));
expect(JSON.stringify(eligibleFindings)).toBe(JSON.stringify(findingsSnapshot));
});
it("modelName resolved from deps", async () => {
const fake = fakeProvider();
await synthesizeInvestigationOverview(
{ situationGraph: graph, findings: [], plausibleInterpretations: [] },
{ provider: fake, modelName: "test-model" }
);
expect(fake.generateReconstruction.mock.calls[0][1]).toBe("test-model");
});
it("empty findings and interpretations produce valid synthesis call", async () => {
const fake = fakeProvider();
await synthesizeInvestigationOverview(
{ situationGraph: graph, findings: [], plausibleInterpretations: [] },
{ provider: fake }
);
expect(fake.generateReconstruction).toHaveBeenCalledTimes(1);
});
it("reusable filterEligibleFindings produces correct eligible set for overview", () => {
const eligible = filterEligibleFindings(eligibleFindings);
expect(eligible.map((f) => f.id)).toEqual(["f1", "f2"]);
expect(eligible).toHaveLength(2);
});
});