/** * Targeted tests for Defect 1 (parent-decision closure in episode mode) * and Defect 2 (comparability reasoning-state override routing). */ import { describe, expect, it } from "vitest"; import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js"; import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; // ── Helpers ──────────────────────────────────────────────────────── /** Build a parent-decision fixture with options and a material factor. */ function makeParentClosureFixture() { const parent = makeNode({ id: "n-parent", label: "Should we proceed?", description: "Parent decision context.", kind: "unknown", status: "unknown", }); const optA = makeNode({ id: "opt-a", label: "Option A", description: "Path A.", kind: "option", status: "known", }); const optB = makeNode({ id: "opt-b", label: "Option B", description: "Path B.", kind: "option", status: "known", }); const factor = makeNode({ id: "n-factor", label: "Material uncertainty factor", description: "A material factor for option A.", kind: "unknown", status: "unknown", }); return { graph: makeGraph({ centralStatement: "Choosing between paths A and B.", nodes: [parent, optA, optB, factor], edges: [ makeEdge({ id: "e-oa-to-p", fromNodeId: optA.id, toNodeId: parent.id, relationship: "contained_in" }), makeEdge({ id: "e-ob-to-p", fromNodeId: optB.id, toNodeId: parent.id, relationship: "contained_in" }), makeEdge({ id: "e-fa", fromNodeId: factor.id, toNodeId: optA.id, relationship: "may_cause" }), ], activeUnknownNodeId: "n-factor", resolvedNodeIds: [], currentSummary: "Parent closure fixture.", }), ids: { parent: parent.id, optA: optA.id, optB: optB.id, factor: factor.id }, }; } /** Build a comparability fixture. */ function makeComparabilityFixture() { const compUnknown = makeNode({ id: "n-comparability", label: "Figures on same basis?", description: "Uncertainty about comparability of two figures.", kind: "unknown", status: "unknown", }); const optA = makeNode({ id: "opt-a", label: "Option A", description: "Path A.", kind: "option", status: "known", }); const optB = makeNode({ id: "opt-b", label: "Option B", description: "Path B.", kind: "option", status: "known", }); const parent = makeNode({ id: "n-parent", label: "Decision context", description: "Parent decision.", kind: "unknown", status: "unknown", }); return { graph: makeGraph({ centralStatement: "Comparability context.", nodes: [compUnknown, optA, optB, parent], edges: [ makeEdge({ id: "e-oa-to-p", fromNodeId: optA.id, toNodeId: parent.id, relationship: "contained_in" }), makeEdge({ id: "e-ob-to-p", fromNodeId: optB.id, toNodeId: parent.id, relationship: "contained_in" }), ], activeUnknownNodeId: "n-comparability", resolvedNodeIds: [], currentSummary: "Comparability fixture.", }), ids: { compUnknown: compUnknown.id, parent: parent.id, optA: optA.id, optB: optB.id }, }; } // ── Defect 1 — Parent-decision closure ───────────────────────────── describe("Defect 1 — parent-decision closure in episode mode", () => { it("legacy explicit closure regression — decision closes with confirmed answer", async () => { const { graph, ids } = makeParentClosureFixture(); const result = await applyValidatedProposal({ situationGraph: graph, proposal: { updatedNodes: [ { nodeId: "n-factor", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Factor resolved.", reason: "Resolved." }, { nodeId: ids.parent, previousStatus: "unknown", newStatus: "known", previousValue: null, newValue: "Decision closed.", reason: "Closes parent." }, ], addedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: ["n-factor"], affectedNodeIds: [ids.parent], selectedQuestion: null, }, answer: "no other material uncertainty remains", }); expect(result.success).toBe(true); const parent = result.updatedSituationGraph.nodes.find((n) => n.id === ids.parent); expect(parent?.status).toBe("known"); }); it("episode closure — no authority prevents terminal parent closure", async () => { const { graph, ids } = makeParentClosureFixture(); const result = await applyValidatedProposal({ situationGraph: graph, proposal: { updatedNodes: [ { nodeId: "n-factor", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Factor resolved.", reason: "Resolved." }, { nodeId: ids.parent, previousStatus: "unknown", newStatus: "known", previousValue: null, newValue: "Decision closed.", reason: "Closes parent." }, ], addedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: ["n-factor"], affectedNodeIds: [ids.parent], selectedQuestion: null, }, evidenceContext: { isCompletedEpisode: true, episodeEvidence: { turns: [ { contributionId: "c1", sequence: 1, question: "What did you find?", answer: "All looks good." }, { contributionId: "c2", sequence: 2, question: "Anything else uncertain?", answer: "No.", }, ], }, }, }); expect(result.success).toBe(true); // Factor resolved — valid update preserved. const factor = result.updatedSituationGraph.nodes.find((n) => n.id === "n-factor"); expect(factor?.status).toBe("resolved"); // Parent terminal closure prevented — fail-closed. const parent = result.updatedSituationGraph.nodes.find((n) => n.id === ids.parent); expect(parent?.status).not.toBe("known"); }); it("episode closure — explicit authority allows closure", async () => { const { graph, ids } = makeParentClosureFixture(); const result = await applyValidatedProposal({ situationGraph: graph, proposal: { updatedNodes: [ { nodeId: "n-factor", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Factor resolved.", reason: "Resolved." }, { nodeId: ids.parent, previousStatus: "unknown", newStatus: "known", previousValue: null, newValue: "Decision closed.", reason: "Closes parent." }, ], addedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: ["n-factor"], affectedNodeIds: [ids.parent], selectedQuestion: null, }, evidenceContext: { isCompletedEpisode: true, episodeEvidence: { turns: [ { contributionId: "c1", sequence: 1, question: "What did you find?", answer: "All looks good." }, { contributionId: "c2", sequence: 2, question: "Is there anything else material that could change which option is better?", answer: "No, there is no remaining material uncertainty.", }, ], }, }, }); expect(result.success).toBe(true); const factor = result.updatedSituationGraph.nodes.find((n) => n.id === "n-factor"); expect(factor?.status).toBe("resolved"); // Explicit authority Q/A pair present — parent closes. const parent = result.updatedSituationGraph.nodes.find((n) => n.id === ids.parent); expect(parent?.status).toBe("known"); }); it("focused question + confirming answer does NOT authorise closure", async () => { // Required 1 — focused false-positive guard const { graph, ids } = makeParentClosureFixture(); const result = await applyValidatedProposal({ situationGraph: graph, proposal: { updatedNodes: [ { nodeId: "n-factor", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Factor resolved.", reason: "Resolved." }, { nodeId: ids.parent, previousStatus: "unknown", newStatus: "known", previousValue: null, newValue: "Decision closed.", reason: "Closes parent." }, ], addedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: ["n-factor"], affectedNodeIds: [ids.parent], selectedQuestion: null, }, evidenceContext: { isCompletedEpisode: true, episodeEvidence: { turns: [ { contributionId: "c1", sequence: 1, question: "What did you find?", answer: "All looks good." }, { contributionId: "c2", sequence: 2, question: "Is there any remaining uncertainty about the supplier?", answer: "No, there is no remaining material uncertainty.", }, ], }, }, }); expect(result.success).toBe(true); const factor = result.updatedSituationGraph.nodes.find((n) => n.id === "n-factor"); expect(factor?.status).toBe("resolved"); // Question is focused, NOT parent-decision authority — closure stripped. const parent = result.updatedSituationGraph.nodes.find((n) => n.id === ids.parent); expect(parent?.status).not.toBe("known"); }); it("cross-turn question and answer mixing does NOT authorise closure", async () => { // Required 3 — cross-turn mixing forbidden const { graph, ids } = makeParentClosureFixture(); const result = await applyValidatedProposal({ situationGraph: graph, proposal: { updatedNodes: [ { nodeId: "n-factor", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Factor resolved.", reason: "Resolved." }, { nodeId: ids.parent, previousStatus: "unknown", newStatus: "known", previousValue: null, newValue: "Decision closed.", reason: "Closes parent." }, ], addedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: ["n-factor"], affectedNodeIds: [ids.parent], selectedQuestion: null, }, evidenceContext: { isCompletedEpisode: true, episodeEvidence: { turns: [ { contributionId: "c1", sequence: 1, question: "Is there anything else material that could change which option is better?", answer: "I need to check the budget.", }, { contributionId: "c2", sequence: 2, question: "Are you satisfied with the analysis so far?", answer: "No, there is no remaining material uncertainty.", }, ], }, }, }); expect(result.success).toBe(true); const factor = result.updatedSituationGraph.nodes.find((n) => n.id === "n-factor"); expect(factor?.status).toBe("resolved"); // Qualifying question and confirming answer are on different turns — closure stripped. const parent = result.updatedSituationGraph.nodes.find((n) => n.id === ids.parent); expect(parent?.status).not.toBe("known"); }); it("Finding agree grants closure: NO", async () => { const { graph, ids } = makeParentClosureFixture(); const result = await applyValidatedProposal({ situationGraph: graph, proposal: { updatedNodes: [ { nodeId: "n-factor", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Factor resolved.", reason: "Resolved." }, { nodeId: ids.parent, previousStatus: "unknown", newStatus: "known", previousValue: null, newValue: "Decision closed.", reason: "Closes parent." }, ], addedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: ["n-factor"], affectedNodeIds: [ids.parent], selectedQuestion: null, }, evidenceContext: { isCompletedEpisode: true, episodeEvidence: { turns: [ { contributionId: "c1", sequence: 1, question: "What did you find?", answer: "All looks good." }, ], eligibleCanonicalFindings: [ { findingId: "f-1", contributionId: "c1", proposition: "No material uncertainty.", endorsement: "agree" }, ], }, }, }); expect(result.success).toBe(true); // Factor resolved — valid update preserved. const factor = result.updatedSituationGraph.nodes.find((n) => n.id === "n-factor"); expect(factor?.status).toBe("resolved"); // No qualifying Q/A pair — parent closure prevented despite Finding agree. const parent = result.updatedSituationGraph.nodes.find((n) => n.id === ids.parent); expect(parent?.status).not.toBe("known"); }); }); // ── Defect 2 — Comparability routing ──────────────────────────────── describe("Defect 2 — comparability reasoning-state override routing", () => { it("comparability relevant turn is not first — episode matches legacy semantics for the right Q/A pair", async () => { const { graph, ids } = makeComparabilityFixture(); const result = await applyValidatedProposal({ situationGraph: graph, proposal: { updatedNodes: [ { nodeId: ids.compUnknown, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Confirmed comparable.", reason: "Resolved." }, ], addedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [ids.compUnknown], affectedNodeIds: [], selectedQuestion: null, }, evidenceContext: { isCompletedEpisode: true, episodeEvidence: { turns: [ // turn 1: unrelated { contributionId: "c1", sequence: 1, question: "What evidence do you have?", answer: "Revenue data." }, // turn 2: comparability Q/A (not first) { contributionId: "c2", sequence: 2, question: "Are these figures on the same basis and at the same scale?", answer: "Yes, both figures cover the same basis.", }, ], }, }, }); expect(result.success).toBe(true); // Reasoning state should reflect comparability confirmation from turn 2. const rs = result.updatedSituationGraph.reasoningState; expect(rs?.comparabilityStatus).toBe("confirmed"); expect(rs?.comparabilityReason).toContain("confirmed"); }); it("comparability absent — no fabricated confirmation", async () => { const { graph, ids } = makeComparabilityFixture(); const result = await applyValidatedProposal({ situationGraph: graph, proposal: { updatedNodes: [ { nodeId: ids.compUnknown, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Resolved.", reason: "Resolved." }, ], addedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [ids.compUnknown], affectedNodeIds: [], selectedQuestion: null, }, evidenceContext: { isCompletedEpisode: true, episodeEvidence: { turns: [ { contributionId: "c1", sequence: 1, question: "Q?", answer: "A." }, { contributionId: "c2", sequence: 2, question: "Any other concerns?", answer: "No.", }, ], }, }, }); expect(result.success).toBe(true); // No comparability pair found — status remains unchanged (uncertain). const rs = result.updatedSituationGraph.reasoningState; expect(rs?.comparabilityStatus).not.toBe("confirmed"); }); });