diff --git a/lib/graph/current-understanding-synthesis.js b/lib/graph/current-understanding-synthesis.js index 10bc39b..1fa1c2a 100644 --- a/lib/graph/current-understanding-synthesis.js +++ b/lib/graph/current-understanding-synthesis.js @@ -52,31 +52,66 @@ export function filterEligibleFindings(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 KNOWN_SUPPORTED_STATUSES = new Set(["known", "supported"]); + +function safeDesc(value) { + return (value && typeof value === "string") ? value : null; +} + +/** + * Evidence-authority projection for CU synthesis. + * + * Includes only: + * - centralStatement (framing context — not independent evidence) + * - known nodes (provider-active graph evidence) + * - supported nodes (provider-active graph evidence) + * - eligible Findings (from the findings parameter) + * + * Explicitly excludes from provider-active synthesis input: + * provisional nodes, unknown nodes, resolved nodes, edges, + * activeUnknownNodeId, resolvedNodeIds, currentSummary, + * reasoningState, confidence/control metadata, dependency/control fields. + */ +function buildGraphEvidenceProjection(graphInfo) { + const knownNodes = (graphInfo.nodes ?? []) + .filter((n) => KNOWN_SUPPORTED_STATUSES.has(n.status)) + .map((n) => ({ + kind: n.kind ?? null, + label: safeDesc(n.label), + description: safeDesc(n.description), + value: n.value ?? null, + unit: n.unit ?? null, + status: n.status ?? null, + })); + + const supportedNodes = (graphInfo.nodes ?? []) + .filter((n) => KNOWN_SUPPORTED_STATUSES.has(n.status)) + .filter((n) => n.status !== "known") + .map((n) => ({ + kind: n.kind ?? null, + label: safeDesc(n.label), + description: safeDesc(n.description), + value: n.value ?? null, + unit: n.unit ?? null, + status: n.status ?? null, + })); + + const centralStatement = safeDesc(graphInfo.centralStatement) || ""; + + return { centralStatement, knownNodes, supportedNodes }; +} + +export function buildSynthesisPrompt(situationGraph, findings) { + // Structured evidence projection for the model + const evidence = buildGraphEvidenceProjection(situationGraph); + + // Eligible Finding sections (preserve existing agree vs null distinction) const findingsSections = []; if (findings.length > 0) { const agreed = findings.filter((f) => f.userDisposition === "agree"); - const provisional = findings.filter( + const working = findings.filter( (f) => f.userDisposition === null ); @@ -90,10 +125,10 @@ export function buildSynthesisPrompt(situationGraph, findings) { }); } - if (provisional.length > 0) { + if (working.length > 0) { findingsSections.push({ - label: "Provisional Findings (working interpretation)", - items: provisional.map((f) => ({ + label: "Working Premises", + items: working.map((f) => ({ proposition: f.proposition, id: f.id ?? null, })), @@ -101,14 +136,26 @@ export function buildSynthesisPrompt(situationGraph, findings) { } } - // 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")}` : ""; + // Human-readable node display for the prompt + function formatNodeSection(title, nodes) { + if (!nodes || nodes.length === 0) return ""; + const items = nodes.map( + (n) => ` ${title}: kind=${n.kind}, label="${n.label}", value=${n.value ? n.value + (n.unit ? " (" + n.unit + ")" : "") : null} — ${n.description ?? "(no description)"} [${n.status}]` + ); + return "\n" + items.join("\n"); + } + + const knownSection = formatNodeSection("Known", evidence.knownNodes); + const supportedItem = formatNodeSection("Supported", evidence.supportedNodes); const prompt = `You are producing a Current Understanding narrative from investigation evidence. -Canonical Situation Graph: -${JSON.stringify(graphInfo, null, 2)}${nodesSection}${edgesSection} +Situation Framing: +${evidence.centralStatement ? " Central Statement: " + evidence.centralStatement : "(none)"} + +Provider-Active Evidence: +Known Facts:${knownSection} +Supported Inferences:${supportedItem} Eligible Findings: ${findingsSections.length > 0 @@ -116,14 +163,15 @@ ${findingsSections.length > 0 : "(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: +1. Current Understanding describes only established or supported understanding from the evidence supplied here. +2. Do not introduce or describe open questions, unresolved uncertainties, assumptions, provisional hypotheses, speculative explanations, or future investigation needs. +3. Produce exactly ONE coherent narrative paragraph (or short multi-sentence paragraph) that represents the Current Understanding of the situation. +4. 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. +5. 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. +6. Use only information present in the evidence above. The centralStatement is framing context, not independent evidence. +7. Return ONLY a JSON object with this exact structure: {"currentUnderstanding": "your narrative here"} -7. The currentUnderstanding value must be a non-empty string. +8. The currentUnderstanding value must be a non-empty string. Return ONLY the JSON object. No markdown, no explanation, no preamble.`; @@ -204,7 +252,7 @@ export async function synthesizeCurrentUnderstanding( // 2. Eligibility normalization (domain seam responsibility) const eligibleFindings = filterEligibleFindings(allFindings); - // 3. Build synthesis prompt (uses full canonical graph, not just centralStatement) + // 3. Build synthesis prompt (uses evidence-authority projection, not raw graph) const prompt = buildSynthesisPrompt(situationGraph, eligibleFindings); // 4. Resolve provider — DI fallback to configured default diff --git a/tests/graph/current-understanding-synthesis.test.js b/tests/graph/current-understanding-synthesis.test.js index d0206ae..2ea3bd0 100644 --- a/tests/graph/current-understanding-synthesis.test.js +++ b/tests/graph/current-understanding-synthesis.test.js @@ -8,31 +8,52 @@ import { // ── 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" }, - ], +/** A graph where only centralStatement is present — no known/supported nodes */ +const emptyGraph = { + centralStatement: "Complaints increased by 35% while production increased by 40%.", }; -const makeFinding = (overrides = {}) => ({ - id: `find-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - proposition: "Supplier delays caused production halts", +/** 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", - ...overrides, }); -// Fake provider factory for tests — always has generateReconstruction -// If defaultResponse is passed, use it; otherwise default to success. +/** 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 () => { return typeof defaultResponse === "function" @@ -41,134 +62,267 @@ const makeFakeProvider = (defaultResponse) => ({ }), }); -// ── Eligibility tests ─────────────────────────────────────── +// ── Eligibility tests (unchanged — domain invariant) ──────── describe("filterEligibleFindings — eligibility contract", () => { it("includes null disposition → eligible", () => { - const findings = [makeFinding({ userDisposition: null })]; - const result = filterEligibleFindings(findings); + const result = filterEligibleFindings([workingFinding()]); expect(result).toHaveLength(1); expect(result[0].userDisposition).toBeNull(); }); it("includes agree disposition → eligible", () => { - const findings = [makeFinding({ userDisposition: "agree" })]; - const result = filterEligibleFindings(findings); + const result = filterEligibleFindings([agreedFinding()]); 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); + expect(filterEligibleFindings([{ userDisposition: "not_quite" }])).toHaveLength(0); }); it("excludes not_relevant disposition → ineligible", () => { - const findings = [makeFinding({ userDisposition: "not_relevant" })]; - const result = filterEligibleFindings(findings); - expect(result).toHaveLength(0); + expect(filterEligibleFindings([{ userDisposition: "not_relevant" }])).toHaveLength(0); }); it("excludes rejected evaluation → excluded", () => { - const findings = [makeFinding({ evaluation: "rejected" })]; - const result = filterEligibleFindings(findings); - expect(result).toHaveLength(0); + expect(filterEligibleFindings([{ evaluation: "rejected" }])).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"]); + 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 input returns empty array", () => { + it("null/undefined/empty input returns empty array", () => { expect(filterEligibleFindings(null)).toEqual([]); expect(filterEligibleFindings(undefined)).toEqual([]); expect(filterEligibleFindings([])).toEqual([]); }); }); -// ── Prompt content tests ──────────────────────────────────── +// ── Evidence projection — status filtering ────────────────── -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%"); +describe("buildSynthesisPrompt — evidence-authority boundary", () => { + // ── A. known retained ──────────────────────────────────── - // 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("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]"); }); - 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"); + // ── 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]"); }); - 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); + // ── 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"); }); - 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"); + // ── 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"'); }); - it("includes confirmed (agree) findings", () => { - const findings = [makeFinding({ userDisposition: "agree" })]; - const prompt = buildSynthesisPrompt(canonicalGraph, findings); + // ── 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("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("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"); }); - 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"); + // ── 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("prompt does NOT request graph mutations or Finding mutations", () => { - const prompt = buildSynthesisPrompt(canonicalGraph, []); - expect(prompt).not.toMatch(/change\s+selectedQuestion/i); + 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)); }); }); -// ── Output validation tests ───────────────────────────────── +// ── 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", () => { @@ -199,153 +353,120 @@ describe("validateSynthesisResponse", () => { }); it("rejects null input", () => { - const result = validateSynthesisResponse(null); - expect(result.valid).toBe(false); + expect(validateSynthesisResponse(null).valid).toBe(false); }); it("rejects undefined input", () => { - const result = validateSynthesisResponse(undefined); - expect(result.valid).toBe(false); + expect(validateSynthesisResponse(undefined).valid).toBe(false); }); }); -// ── Domain function tests ─────────────────────────────────── +// ── Domain function tests (full seam) ────────────────────── 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"); - }); + // ── Evidence inclusion/exclusion at seam level ─────────── - 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 () => { + it("known node content flows to provider via synthesis prompt", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( - { situationGraph: canonicalGraph, findings: [makeFinding({ userDisposition: "not_relevant" })] }, + { situationGraph: fullScenario, findings: [] }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; - expect(prompt).toContain("(none)"); + expect(prompt).toContain("Complaint count increased by 35%"); + expect(prompt).toContain("[known]"); }); - it("rejected evaluation → excluded from synthesis", async () => { + it("supported node content flows to provider via synthesis prompt", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( - { situationGraph: canonicalGraph, findings: [makeFinding({ evaluation: "rejected" })] }, + { situationGraph: fullScenario, findings: [] }, { provider: fake } ); const prompt = fake.generateReconstruction.mock.calls[0][0]; - expect(prompt).toContain("(none)"); + expect(prompt).toContain("Production count increased by 40%"); + expect(prompt).toContain("[supported]"); }); - it("zero eligible Findings → synthesis succeeds from graph alone", async () => { + it("unknown nodes do NOT appear in provider-visible synthesis", async () => { const fake = makeFakeProvider(); - const result = await synthesizeCurrentUnderstanding( - { situationGraph: canonicalGraph, findings: [] }, + await synthesizeCurrentUnderstanding( + { situationGraph: fullScenario, 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)"); + expect(prompt).not.toContain("Baseline period denominator volume"); }); - it("fake provider returns valid narrative → { currentUnderstanding }", async () => { + it("provisional hypothesis does NOT appear in synthesis prompt", async () => { const fake = makeFakeProvider(); - const result = await synthesizeCurrentUnderstanding( - { situationGraph: canonicalGraph, findings: [makeFinding({ userDisposition: "agree" })] }, + await synthesizeCurrentUnderstanding( + { situationGraph: fullScenario, findings: [] }, { provider: fake } ); - expect(result).toEqual({ currentUnderstanding: "Synthesized output" }); + const prompt = fake.generateReconstruction.mock.calls[0][0]; + expect(prompt).not.toContain("Product mix may explain"); }); - 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("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("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("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("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/); + 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 tests ──────────────────────────────────── + // ── Immutability ───────────────────────────────────────── it("graph structurally unchanged after synthesis", async () => { - const graphSnapshot = JSON.parse(JSON.stringify(canonicalGraph)); + const graphSnapshot = JSON.parse(JSON.stringify(fullScenario)); const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( - { situationGraph: canonicalGraph, findings: [] }, + { situationGraph: fullScenario, findings: [] }, { provider: fake } ); - expect(JSON.stringify(canonicalGraph)).toBe(JSON.stringify(graphSnapshot)); + expect(JSON.stringify(fullScenario)).toBe(JSON.stringify(graphSnapshot)); }); it("findings structurally unchanged after synthesis", async () => { - const findings = [makeFinding({ userDisposition: "agree" })]; + const findings = [workingFinding()]; const findingsSnapshot = JSON.parse(JSON.stringify(findings)); const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( - { situationGraph: canonicalGraph, findings }, + { situationGraph: fullScenario, findings }, { provider: fake } ); expect(JSON.stringify(findings)).toBe(JSON.stringify(findingsSnapshot)); }); - // ── Input validation tests ──────────────────────────────── + // ── Input validation (unchanged) ──────────────────────── it("missing situationGraph → throws 400", async () => { await expect( @@ -361,7 +482,7 @@ describe("synthesizeCurrentUnderstanding — full seam", () => { it("findings as non-array → throws 400", async () => { await expect( - synthesizeCurrentUnderstanding({ situationGraph: canonicalGraph, findings: "string" }, { provider: makeFakeProvider() }) + synthesizeCurrentUnderstanding({ situationGraph: fullScenario, findings: "string" }, { provider: makeFakeProvider() }) ).rejects.toThrow(/findings must be an array/); }); @@ -369,47 +490,36 @@ describe("synthesizeCurrentUnderstanding — full seam", () => { const fake = makeFakeProvider(async () => { throw new Error("provider down"); }); await expect( synthesizeCurrentUnderstanding( - { situationGraph: canonicalGraph, findings: [] }, + { situationGraph: fullScenario, findings: [] }, { provider: fake } ) ).rejects.toThrow(/Synthesis provider call failed|provider down/); }); - it("no provider provided — falls through to getProvider() which needs env vars", async () => { + it("no provider provided — falls through to getProvider()", async () => { await expect( synthesizeCurrentUnderstanding( - { situationGraph: canonicalGraph, findings: [] }, + { situationGraph: fullScenario, findings: [] }, {} ) ).rejects.toThrow(/OLLAMA_BASE_URL/); }); + // ── Configured model resolution (unchanged) ───────────── - // ── Configured model resolution ────────────────────────── - - it("default synthesis path resolves configured modelName — provider receives non-null", async () => { + it("default synthesis path resolves configured modelName", async () => { const fake = makeFakeProvider(); - - // Stub the configured model so test does not depend on dev-machine .env.local const savedModel = process.env.OLLAMA_MODEL; process.env.OLLAMA_MODEL = "configured-model-v0.50"; - try { - const result = await synthesizeCurrentUnderstanding( - { situationGraph: canonicalGraph, findings: [] }, + await synthesizeCurrentUnderstanding( + { situationGraph: fullScenario, findings: [] }, { provider: fake } ); - - expect(result.currentUnderstanding).toBe("Synthesized output"); - - // KEY ASSERTION: configured model must flow to provider const receivedModel = fake.generateReconstruction.mock.calls[0][1]; expect(receivedModel).toBeDefined(); expect(receivedModel).not.toBeNull(); - expect(typeof receivedModel).toBe("string"); - expect(receivedModel.length).toBeGreaterThan(0); } finally { - // Restore original env value (may be undefined) if (savedModel == null) { delete process.env.OLLAMA_MODEL; } else { @@ -418,37 +528,96 @@ describe("synthesizeCurrentUnderstanding — full seam", () => { } }); - it("explicit modelName dependency overrides default — provider receives injected model", async () => { + it("explicit modelName dependency overrides default", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( - { situationGraph: canonicalGraph, findings: [] }, + { situationGraph: fullScenario, findings: [] }, { provider: fake, modelName: "test-model-v0.50" } ); - expect(fake.generateReconstruction.mock.calls[0][1]).toBe("test-model-v0.50"); }); - // ── Provider sees full graph, not just centralStatement ─── + // ── Provider sees evidence projection, not raw graph ───── - it("provider receives full canonical graph content (nodes + edges + centralStatement)", async () => { + it("provider receives structured evidence projection (not full graph dump)", async () => { const fake = makeFakeProvider(); await synthesizeCurrentUnderstanding( - { situationGraph: canonicalGraph, findings: [] }, + { situationGraph: fullScenario, 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"); + // 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/); }); });