diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js index f80ac42..b986c3f 100644 --- a/lib/graph/apply-proposal.js +++ b/lib/graph/apply-proposal.js @@ -3971,9 +3971,11 @@ export async function applyValidatedProposal({ proposal, previousQuestion = null, answer = null, + evidenceContext = null, provider = null, modelName = null, }) { + const isLegacySingleTurn = !evidenceContext; const graphValidation = situationGraphSchema.safeParse(situationGraph); const proposalValidation = graphUpdateSchema.safeParse(proposal); @@ -4030,11 +4032,15 @@ export async function applyValidatedProposal({ ); // ── 60B.80 — normalise terminal parent closure without explicit confirmation - const ownershipChanges = reconcileDecisionClosureOwnership( - situationGraph, - reconciledProposal.proposal, - answer, - ); + // legacy only: episode evidence does not supply a single-answer confirmation string; + // Done and Finding agree are NOT closure authority (settled architecture rule). + const ownershipChanges = isLegacySingleTurn + ? reconcileDecisionClosureOwnership( + situationGraph, + reconciledProposal.proposal, + answer, + ) + : { strippedUpdates: [], strippedResolvedIds: [] }; if (ownershipChanges.strippedResolvedIds.length > 0) { // Reconciler may have added selectedQuestion = null because the parent // appeared resolved during reconciliation. If we stripped it, restore @@ -4130,12 +4136,16 @@ export async function applyValidatedProposal({ validatedProposal, ); proposalCompatibilityErrors.push(...selectedQuestionValidation.errors); - proposalCompatibilityErrors.push( - ...validateAnswerMeaningCompatibilityWithRawAnswer({ - answer, - proposal: validatedProposal, - }), - ); + // legacy only: raw-answer fidelity is a single-turn safeguard. + // Episode evidence carries structured turns — no synthetic answer is constructed. + if (isLegacySingleTurn) { + proposalCompatibilityErrors.push( + ...validateAnswerMeaningCompatibilityWithRawAnswer({ + answer, + proposal: validatedProposal, + }), + ); + } proposalCompatibilityErrors.push( ...validateAnswerMeaningAlignment(validatedProposal), ); @@ -4165,10 +4175,14 @@ export async function applyValidatedProposal({ ? findNodeById(graphSnapshot, previousActiveUnknownNodeId) : null; const affectedNodeIds = buildAffectedNodeIds(graphSnapshot, proposalSnapshot); + // For episode evidence: extract turn text from the ordered Q/A sequence. + const _epTurns = evidenceContext?.isCompletedEpisode ? evidenceContext.episodeEvidence.turns : null; + const _derivedQuestion = isLegacySingleTurn ? previousQuestion : (_epTurns?.[0]?.question ?? ""); + const _derivedAnswer = isLegacySingleTurn ? answer : (_epTurns?.[0]?.answer ?? ""); const reasoningResolution = deriveReasoningStateOverride({ graph: graphSnapshot, - previousQuestion, - answer, + previousQuestion: _derivedQuestion, + answer: _derivedAnswer, resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds, }); diff --git a/tests/graph/episode-evidence-boundary.test.js b/tests/graph/episode-evidence-boundary.test.js new file mode 100644 index 0000000..eb6a2e5 --- /dev/null +++ b/tests/graph/episode-evidence-boundary.test.js @@ -0,0 +1,558 @@ +/** + * 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); + }); +});