/** * Tests for episode evidence at the graph application boundary. * * Scope: applyValidatedProposal accepts structured completed-episode evidence * without collapsing it into a synthetic single answer, and preserves existing * single-turn behaviour unchanged. */ import { describe, expect, it } from "vitest"; import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js"; import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; // ── production-style helpers ─────────────────────────────────────── function makeSituationGraph() { const parent = makeNode({ id: "n-parent", label: "Commercial justification for continuing development", description: "Need to know whether this solves a genuine problem and is commercially justified.", kind: "unknown", status: "unknown", confidence: "medium", }); const child1 = makeNode({ id: "n-child-1", label: "Whether the idea solves a genuine problem", description: "Need evidence about whether there is real user demand.", kind: "unknown", status: "unknown", confidence: "medium", parentId: parent.id, }); const child2 = makeNode({ id: "n-child-2", label: "Whether people would value it enough to pay", description: "Need evidence about willingness to pay.", kind: "unknown", status: "unknown", confidence: "medium", parentId: parent.id, }); const anchor = makeNode({ id: "n-anchor", label: "Market observation anchor", description: "Anchor state introduced by the answer.", kind: "state", status: "known", confidence: "low", }); return makeGraph({ centralStatement: "I have developed a new reasoning method and want to determine whether continuing development is commercially justified.", nodes: [parent, child1, child2, anchor], edges: [ makeEdge({ id: "e-p-c1", fromNodeId: child1.id, toNodeId: parent.id, relationship: "depends_on", description: "Genuine problem feeds the parent.", }), makeEdge({ id: "e-p-c2", fromNodeId: child2.id, toNodeId: parent.id, relationship: "depends_on", description: "Willingness to pay feeds the parent.", }), ], activeUnknownNodeId: "n-child-1", resolvedNodeIds: [], currentSummary: "Episode evidence boundary fixture", }); } function makeResolvedProposal(resolvedIds = ["n-child-1"]) { return { addedNodes: [ makeNode({ id: "n-anchor2", label: "Update anchor 2", description: "Anchor state.", kind: "state", status: "known", confidence: "low", }), ], updatedNodes: resolvedIds.map((id) => ({ nodeId: id, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: `answer:${id}`, reason: "resolved child", })), addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: resolvedIds, affectedNodeIds: [], selectedQuestion: null, }; } function makeLegacyAnswer() { return "Yes, I confirm there is no further material uncertainty."; } // ── Legacy single-turn regression ────────────────────────────────── describe("legacy single-turn path", () => { it("continues to work without evidenceContext (backward-compatible signature)", async () => { const graph = makeSituationGraph(); const proposal = makeResolvedProposal(["n-child-1"]); const result = await applyValidatedProposal({ situationGraph: graph, proposal, answer: makeLegacyAnswer(), }); expect(result.success).toBe(true); }); it("legacy raw-answer compatibility check still runs (Regression A)", async () => { const graph = makeSituationGraph(); // Use unstructured answerMeaning (no structured supportCategory/resolutionGuidance) // so that validateAnswerMeaningCompatibilityWithRawAnswer actually executes. // Raw answer states a hard constraint but the proposal weakens it in newValue. const proposalWithWeakening = { addedNodes: [ makeNode({ id: "n-anchor-lr", label: "Update anchor", description: "Anchor state.", kind: "state", status: "known", confidence: "low", }), ], updatedNodes: [ { nodeId: "n-child-1", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "It would be good to proceed.", // weaker than raw constraint reason: "Evidence supports proceeding.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: ["n-child-1"], affectedNodeIds: [], selectedQuestion: null, answerMeaning: { userSupportedMeaning: "It would be good to proceed.", supportCategory: null, resolutionGuidance: null, }, }; const result = await applyValidatedProposal({ situationGraph: graph, proposal: proposalWithWeakening, answer: "We MUST proceed — no exceptions.", // explicit hard constraint in raw answer }); // legacy path: raw-answer compatibility check rejects weakening expect(result.success).toBe(false); expect(result.stage).toBe("proposal_compatibility"); }); }); // ── Episode evidence path ────────────────────────────────────────── describe("episode evidence at application boundary", () => { it("accepts structured completed-episode evidence without collapsing to synthetic answer", async () => { const graph = makeSituationGraph(); const proposal = makeResolvedProposal(["n-child-1"]); const episodeEvidence = { isCompletedEpisode: true, episodeEvidence: { situationGraph: graph, targetNodeId: "n-parent", turns: [ { contributionId: "c-1", sequence: 1, question: "Should we proceed with the investment?", answer: "Yes, the risk assessment is complete.", }, { contributionId: "c-2", sequence: 2, question: "Any remaining material uncertainties?", answer: "No, all findings are aligned.", }, ], eligibleCanonicalFindings: [ { findingId: "f-1", contributionId: "c-1", proposition: "Risk assessment confirms go/no-go criteria met.", sourceObservation: "Risk analysis document v3.", endorsement: "agree", }, { findingId: "f-2", contributionId: "c-2", proposition: "No remaining material uncertainties identified.", sourceObservation: "User confirmation response.", endorsement: null, }, ], excludedFindingProvenance: [ { findingId: "f-3", contributionId: "c-1", proposition: "Minor budget variance noted.", sourceObservation: "Budget report.", disposition: "not_relevant", }, ], }, }; const result = await applyValidatedProposal({ situationGraph: graph, proposal, evidenceContext: episodeEvidence, }); expect(result.success).toBe(true); }); it("ordered turns remain distinct (no aggregation)", async () => { const graph = makeSituationGraph(); const proposal = makeResolvedProposal(["n-child-1"]); const episodeEvidence = { isCompletedEpisode: true, episodeEvidence: { situationGraph: graph, targetNodeId: "n-parent", turns: [ { contributionId: "c-10", sequence: 1, question: "Q1 distinct text?", answer: "A1 distinct text.", }, { contributionId: "c-11", sequence: 2, question: "Q2 distinct text?", answer: "A2 distinct text.", }, { contributionId: "c-12", sequence: 3, question: "Q3 distinct text?", answer: "A3 distinct text.", }, ], eligibleCanonicalFindings: [], excludedFindingProvenance: [], }, }; const result = await applyValidatedProposal({ situationGraph: graph, proposal, evidenceContext: episodeEvidence, }); expect(result.success).toBe(true); }); it("eligible Findings remain structured evidence", async () => { const graph = makeSituationGraph(); const proposal = makeResolvedProposal(["n-child-1"]); const eligibleFindings = [ { findingId: "f-aggree", contributionId: "c-a", proposition: "Confirmed proposition A.", sourceObservation: "Source A.", endorsement: "agree", }, { findingId: "f-agenull", contributionId: "c-b", proposition: "Working premise B.", sourceObservation: "Source B.", endorsement: null, }, ]; const episodeEvidence = { isCompletedEpisode: true, episodeEvidence: { situationGraph: graph, targetNodeId: "n-parent", turns: [ { contributionId: "c-a", sequence: 1, question: "Q?", answer: "A." }, { contributionId: "c-b", sequence: 2, question: "Q?", answer: "A." }, ], eligibleCanonicalFindings: eligibleFindings, excludedFindingProvenance: [], }, }; const result = await applyValidatedProposal({ situationGraph: graph, proposal, evidenceContext: episodeEvidence, }); expect(result.success).toBe(true); }); it("no synthetic answer is constructed for episode path", async () => { // In episode mode, the `answer` parameter is omitted and structured turns // are provided via evidenceContext. Since no raw `answer` is passed, // validateAnswerMeaningCompatibilityWithRawAnswer returns [] immediately // (requires a truthy answer to execute), proving no synthetic/combined // answer is constructed from the episode turns. const graph = makeSituationGraph(); // Use structured meaning (bypasses raw-answer compatibility) with valid alignment const proposalWithMeaning = { addedNodes: [ makeNode({ id: "n-anchor-na", label: "Update anchor", description: "Anchor state.", kind: "state", status: "known", confidence: "low", }), ], updatedNodes: [ { nodeId: "n-child-1", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Proceed with full commitment.", reason: "Evidence supports it.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: ["n-child-1"], affectedNodeIds: [], selectedQuestion: null, structuralActionRequired: true, answerMeaning: { supportCategory: "explicit_hard_constraint", resolutionGuidance: "must_resolve", userSupportedMeaning: "Proceed with full commitment.", }, }; const episodeEvidence = { isCompletedEpisode: true, episodeEvidence: { situationGraph: graph, targetNodeId: "n-parent", turns: [ { contributionId: "c-1", sequence: 1, question: "Go?", answer: "Absolutely yes." }, { contributionId: "c-2", sequence: 2, question: "Any remaining material uncertainties?", answer: "No." }, ], eligibleCanonicalFindings: [], excludedFindingProvenance: [], }, }; // No `answer` parameter — evidenceContext drives the episode branch. const result = await applyValidatedProposal({ situationGraph: graph, proposal: proposalWithMeaning, evidenceContext: episodeEvidence, }); expect(result.success).toBe(true); }); it("proposal-internal validation still runs in episode mode", async () => { const graph = makeSituationGraph(); // Duplicate node ID — structural violation that proposal-internal validation catches. const proposalWithDuplicateNodes = { addedNodes: [ { id: "n-child-1", // duplicates existing node ID kind: "unknown", status: "unknown", confidence: "medium", label: "Duplicate unknown", value: null, dependsOn: [], }, ], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], selectedQuestion: null, }; const episodeEvidence = { isCompletedEpisode: true, episodeEvidence: { situationGraph: graph, targetNodeId: "n-parent", turns: [ { contributionId: "c-1", sequence: 1, question: "Q?", answer: "A." }, ], eligibleCanonicalFindings: [], excludedFindingProvenance: [], }, }; const result = await applyValidatedProposal({ situationGraph: graph, proposal: proposalWithDuplicateNodes, evidenceContext: episodeEvidence, }); // Proposal-internal validation (structural) still runs and rejects. expect(result.success).toBe(false); expect(result.stage).toBe("proposal_compatibility"); }); it("structured episode is available for subsequent safeguard adaptation", async () => { const graph = makeSituationGraph(); const proposal = makeResolvedProposal(["n-child-1"]); const turns = [ { contributionId: "c-1", sequence: 1, question: "First turn?", answer: "First answer." }, { contributionId: "c-2", sequence: 2, question: "Second turn?", answer: "Second answer." }, ]; const episodeEvidence = { isCompletedEpisode: true, episodeEvidence: { situationGraph: graph, targetNodeId: "n-parent", turns, eligibleCanonicalFindings: [], excludedFindingProvenance: [], }, }; const result = await applyValidatedProposal({ situationGraph: graph, proposal, evidenceContext: episodeEvidence, }); expect(result.success).toBe(true); expect(result.updatedSituationGraph.nodes.find((n) => n.id === "n-child-1")?.status).toBe("resolved"); }); it("preserves existing graph mutation behaviour in episode mode", async () => { const graph = makeSituationGraph(); const proposal = makeResolvedProposal(["n-child-1"]); const originalParentStatus = graph.nodes.find((n) => n.id === "n-parent")?.status; const episodeEvidence = { isCompletedEpisode: true, episodeEvidence: { situationGraph: graph, targetNodeId: "n-parent", turns: [ { contributionId: "c-1", sequence: 1, question: "Q?", answer: "A." }, ], eligibleCanonicalFindings: [], excludedFindingProvenance: [], }, }; const result = await applyValidatedProposal({ situationGraph: graph, proposal, evidenceContext: episodeEvidence, }); expect(result.success).toBe(true); // Graph was mutated (expected) expect(result.updatedSituationGraph.nodes.find((n) => n.id === "n-child-1")?.status).toBe("resolved"); // Original graph not mutated (atomicity test) expect(graph.nodes.find((n) => n.id === "n-parent")?.status).toBe(originalParentStatus); }); }); // ── Signature compatibility ──────────────────────────────────────── describe("applyValidatedProposal signature", () => { it("works with minimal legacy call (situationGraph + proposal only)", async () => { const graph = makeSituationGraph(); const result = await applyValidatedProposal({ situationGraph: graph, proposal: makeResolvedProposal(["n-child-1"]), }); expect(result.success).toBe(true); }); it("works with legacy answer parameter still passed", async () => { const graph = makeSituationGraph(); const result = await applyValidatedProposal({ situationGraph: graph, proposal: makeResolvedProposal(["n-child-1"]), answer: "Yes.", }); expect(result.success).toBe(true); }); it("works with both evidenceContext and legacy parameters (evidenceContext takes precedence)", async () => { const graph = makeSituationGraph(); const proposal = makeResolvedProposal(["n-child-1"]); const episodeEvidence = { isCompletedEpisode: true, episodeEvidence: { situationGraph: graph, targetNodeId: "n-parent", turns: [ { contributionId: "c-1", sequence: 1, question: "Q?", answer: "A." }, ], eligibleCanonicalFindings: [], excludedFindingProvenance: [], }, }; const result = await applyValidatedProposal({ situationGraph: graph, proposal, previousQuestion: "Legacy question?", answer: "Legacy answer.", // should be ignored — evidenceContext present evidenceContext: episodeEvidence, }); expect(result.success).toBe(true); }); });