import { describe, expect, it } from "vitest"; import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js"; import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; import { validateGraphReferences } from "@/lib/graph/utils.js"; function makeComparabilityUpdateFixture() { const comparabilityUnknown = makeNode({ id: "n-comparability-unknown", label: "Whether the figures are comparable", description: "Need to know whether the figures use the same period, basis, and scale before comparing them.", kind: "unknown", status: "unknown", confidence: "high", }); const revenueObservation = makeNode({ id: "n-revenue-observation", label: "Revenue increased by 18%.", description: "Revenue increased by 18%.", kind: "observation", status: "supported", confidence: "high", }); const cashObservation = makeNode({ id: "n-cash-observation", label: "Cash in the bank decreased over the same period.", description: "Cash in the bank decreased over the same period.", kind: "observation", status: "supported", confidence: "high", }); const unrelatedNode = makeNode({ id: "n-unrelated", label: "Board update", description: "A separate unchanged note.", kind: "state", status: "known", confidence: "low", }); const graph = makeGraph({ centralStatement: "Revenue increased by 18%, but cash in the bank fell over the same period.", nodes: [ comparabilityUnknown, revenueObservation, cashObservation, unrelatedNode, ], edges: [ makeEdge({ id: "e-revenue-comparability", fromNodeId: revenueObservation.id, toNodeId: comparabilityUnknown.id, relationship: "supports", confidence: "medium", description: "Revenue observation requires comparability confirmation.", }), makeEdge({ id: "e-cash-comparability", fromNodeId: cashObservation.id, toNodeId: comparabilityUnknown.id, relationship: "supports", confidence: "medium", description: "Cash observation requires comparability confirmation.", }), ], activeUnknownNodeId: comparabilityUnknown.id, resolvedNodeIds: [], currentSummary: "Initial comparability fixture", reasoningState: { comparabilityStatus: "uncertain", comparabilityReason: "Comparability between the observations is not yet established across period, scale, or measurement basis.", comparabilityEvidence: [], relationshipStatus: "insufficient_information", relationshipReason: "Relationship classification is deferred until comparability is established.", relationshipAssessed: false, contradictionReasoningAllowed: false, reasoningStages: [ { stage: "comparability", status: "uncertain", outcome: "Comparability between the observations is not yet established across period, scale, or measurement basis.", }, { stage: "relationship", status: "insufficient_information", outcome: "not assessed until comparability is established", }, ], }, }); const proposal = { addedNodes: [], updatedNodes: [ { nodeId: comparabilityUnknown.id, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Both figures cover the same accounting period and are taken from the same management accounts.", reason: "The answer confirms the figures are comparable.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [comparabilityUnknown.id], affectedNodeIds: [], selectedQuestion: null, }; return { graph, proposal, comparabilityUnknownId: comparabilityUnknown.id }; } function makeApplicationFixture() { const complaintRateUnknown = makeNode({ id: "n-complaint-rate-unknown", label: "Complaint rate", description: "Need the complaint rate per 100 units", kind: "unknown", status: "unknown", confidence: "high", affects: ["n-quality-deterioration"], }); const staffingUnknown = makeNode({ id: "n-staffing-unknown", label: "Staffing change", description: "Need to know if staffing changed", kind: "unknown", status: "unknown", confidence: "medium", }); const qualityDeterioration = makeNode({ id: "n-quality-deterioration", label: "Quality deterioration conclusion", description: "Conclusion that quality deteriorated", kind: "conclusion", status: "supported", confidence: "medium", dependsOn: ["n-complaint-rate-unknown"], }); const complaintCount = makeNode({ id: "n-complaint-count", label: "Complaint count observation", description: "Complaint count increased", kind: "observation", status: "supported", confidence: "high", value: 135, unit: "count", }); const productionCount = makeNode({ id: "n-production-count", label: "Production count observation", description: "Production increased", kind: "observation", status: "supported", confidence: "high", value: 7100, unit: "units", }); const graph = makeGraph({ centralStatement: "Complaints rose while production also rose.", nodes: [ complaintRateUnknown, staffingUnknown, qualityDeterioration, complaintCount, productionCount, ], edges: [ makeEdge({ id: "e-quality-depends-rate", fromNodeId: complaintRateUnknown.id, toNodeId: qualityDeterioration.id, relationship: "supports", confidence: "medium", description: "The rate informs the quality conclusion", }), ], activeUnknownNodeId: complaintRateUnknown.id, resolvedNodeIds: [], currentSummary: "Initial summary", }); const proposal = { addedNodes: [], updatedNodes: [ { nodeId: complaintRateUnknown.id, previousStatus: "unknown", newStatus: "resolved", previousValue: "2.0 complaints per 100 units", newValue: "1.9 complaints per 100 units", reason: "The answer provides the updated normalized complaint rate.", }, { nodeId: qualityDeterioration.id, previousStatus: "supported", newStatus: "weakened", previousValue: null, newValue: null, reason: "The improved rate weakens the deterioration conclusion.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [complaintRateUnknown.id], affectedNodeIds: [qualityDeterioration.id], selectedQuestion: null, }; return { graph, proposal, ids: { complaintRateUnknown: complaintRateUnknown.id, staffingUnknown: staffingUnknown.id, qualityDeterioration: qualityDeterioration.id, complaintCount: complaintCount.id, productionCount: productionCount.id, }, }; } describe("applyValidatedProposal", () => { it("applies a valid proposal successfully", () => { const { graph, proposal, ids } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal, }); expect(result).toMatchObject({ success: true, graphUpdate: proposal, resolvedUnknownNodeIds: [ids.complaintRateUnknown], previousActiveUnknownNodeId: ids.complaintRateUnknown, newActiveUnknownNodeId: ids.staffingUnknown, }); expect( result.updatedSituationGraph.nodes.find( (node) => node.id === ids.complaintRateUnknown, )?.status, ).toBe("resolved"); expect( result.updatedSituationGraph.nodes.find( (node) => node.id === ids.qualityDeterioration, )?.status, ).toBe("weakened"); }); it("rejects an invalid graph before application", () => { const { graph, proposal } = makeApplicationFixture(); graph.nodes[0].dependsOn.push("missing-node"); const original = JSON.parse(JSON.stringify(graph)); const result = applyValidatedProposal({ situationGraph: graph, proposal, }); expect(result.success).toBe(false); expect(result.stage).toBe("graph_validation"); expect(graph).toEqual(original); }); it("rejects updates referencing nonexistent nodes", () => { const { graph, proposal } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal: { ...proposal, updatedNodes: [ ...proposal.updatedNodes, { nodeId: "ghost-node", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: null, reason: "Invalid reference", }, ], }, }); expect(result).toMatchObject({ success: false, stage: "proposal_compatibility", }); expect(result.errors).toEqual( expect.arrayContaining([ expect.stringContaining( 'Cannot update non-existent node: "ghost-node"', ), ]), ); }); it("rejects added edges with invalid references", () => { const { graph, proposal } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal: { ...proposal, addedEdges: [ makeEdge({ id: "e-invalid", fromNodeId: "missing-node", toNodeId: "n-quality-deterioration", relationship: "supports", confidence: "medium", description: "Invalid edge", }), ], }, }); expect(result.success).toBe(false); expect(result.stage).toBe("proposal_compatibility"); }); it("rejects duplicate IDs", () => { const { graph, proposal, ids } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal: { ...proposal, addedNodes: [ makeNode({ id: ids.qualityDeterioration, label: "Duplicate", description: "Duplicate node id", }), ], }, }); expect(result.success).toBe(false); expect(result.stage).toBe("proposal_compatibility"); expect(result.errors.join(" ")).toContain("duplicate node ID"); }); it("preserves unrelated nodes byte-for-byte", () => { const { graph, proposal, ids } = makeApplicationFixture(); const originalComplaintCount = JSON.stringify( graph.nodes.find((node) => node.id === ids.complaintCount), ); const originalProductionCount = JSON.stringify( graph.nodes.find((node) => node.id === ids.productionCount), ); const result = applyValidatedProposal({ situationGraph: graph, proposal, }); expect(result.success).toBe(true); expect( JSON.stringify( result.updatedSituationGraph.nodes.find( (node) => node.id === ids.complaintCount, ), ), ).toBe(originalComplaintCount); expect( JSON.stringify( result.updatedSituationGraph.nodes.find( (node) => node.id === ids.productionCount, ), ), ).toBe(originalProductionCount); }); it("adds resolved unknowns to resolvedNodeIds", () => { const { graph, proposal, ids } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal, }); expect(result.success).toBe(true); expect(result.updatedSituationGraph.resolvedNodeIds).toContain( ids.complaintRateUnknown, ); expect( result.updatedSituationGraph.nodes.find( (node) => node.id === ids.complaintRateUnknown, )?.status, ).toBe("resolved"); }); it("rejects resolvedUnknownNodeIds that do not reference actual unknown nodes", () => { const { graph, proposal, ids } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal: { ...proposal, resolvedUnknownNodeIds: [ids.qualityDeterioration], }, }); expect(result.success).toBe(false); expect(result.stage).toBe("proposal_compatibility"); expect(result.errors.join(" ")).toContain( "Resolved unknown must reference an existing unknown node", ); }); it("rejects a duplicate semantic node without resolution", () => { const { graph, proposal } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal: { ...proposal, resolvedUnknownNodeIds: [], updatedNodes: proposal.updatedNodes.filter( (update) => update.nodeId !== "n-complaint-rate-unknown", ), addedNodes: [ makeNode({ id: "n-parallel-rate", label: "Complaint rate", description: "Need the complaint rate per 100 units", kind: "observation", status: "supported", confidence: "medium", }), ], }, }); expect(result.success).toBe(false); expect(result.stage).toBe("proposal_compatibility"); expect(result.errors.join(" ")).toContain( "duplicating unresolved unknown meaning", ); }); it("keeps the active unknown when it remains unresolved", () => { const { graph, ids } = makeApplicationFixture(); const proposal = { addedNodes: [], updatedNodes: [ { nodeId: ids.qualityDeterioration, previousStatus: "supported", newStatus: "weakened", previousValue: null, newValue: null, reason: "Only the conclusion changes", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [ids.qualityDeterioration], }; const result = applyValidatedProposal({ situationGraph: graph, proposal, }); expect(result.success).toBe(true); expect(result.previousActiveUnknownNodeId).toBe(ids.complaintRateUnknown); expect(result.newActiveUnknownNodeId).toBe(ids.complaintRateUnknown); }); it("reports affected node ids", () => { const { graph, proposal, ids } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal, }); expect(result.success).toBe(true); expect(result.affectedNodeIds).toEqual( expect.arrayContaining([ ids.complaintRateUnknown, ids.qualityDeterioration, ]), ); }); it("revalidates the completed graph references", () => { const { graph, proposal } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal, }); expect(result.success).toBe(true); expect(validateGraphReferences(result.updatedSituationGraph)).toEqual({ valid: true, errors: [], }); }); it("is atomic on failure", () => { const { graph, proposal } = makeApplicationFixture(); const originalGraph = JSON.parse(JSON.stringify(graph)); const result = applyValidatedProposal({ situationGraph: graph, proposal: { ...proposal, addedEdges: [ makeEdge({ id: "e-bad", fromNodeId: "missing-node", toNodeId: "n-quality-deterioration", relationship: "supports", confidence: "medium", description: "Invalid edge", }), ], }, }); expect(result.success).toBe(false); expect(graph).toEqual(originalGraph); }); it("rejects a proposal with no meaningful change", () => { const { graph } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal: { addedNodes: [], updatedNodes: [ { nodeId: "n-quality-deterioration", previousStatus: null, newStatus: null, previousValue: null, newValue: null, reason: "No change", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], selectedQuestion: null, }, }); expect(result).toMatchObject({ success: false, stage: "proposal_compatibility", }); expect(result.errors).toEqual( expect.arrayContaining([expect.stringContaining("no meaningful change")]), ); }); it("resolves one unknown and adds consequential unknowns with one selected question", () => { const { graph, ids } = makeApplicationFixture(); const proposal = { addedNodes: [ makeNode({ id: "n-commercial-value", label: "Commercial value definition", description: "Need a concrete definition of commercial value because the decision depends on it.", kind: "unknown", status: "unknown", confidence: "high", }), makeNode({ id: "n-demand-evidence", label: "Evidence of demand", description: "Need evidence of demand because it matters to the build decision.", kind: "unknown", status: "unknown", confidence: "medium", }), makeNode({ id: "n-build-decision", label: "Build Confidence Engine decision", description: "Decision situation introduced by the answer.", kind: "state", status: "supported", confidence: "medium", }), ], updatedNodes: [ { nodeId: ids.complaintRateUnknown, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Decision whether to build Confidence Engine", reason: "The answer resolves the original context unknown.", }, ], addedEdges: [ makeEdge({ id: "e-build-commercial-value", fromNodeId: "n-build-decision", toNodeId: "n-commercial-value", relationship: "depends_on", confidence: "medium", description: "The decision depends on defining commercial value.", }), makeEdge({ id: "e-build-demand-evidence", fromNodeId: "n-build-decision", toNodeId: "n-demand-evidence", relationship: "depends_on", confidence: "medium", description: "The decision depends on evidence of demand.", }), ], removedEdgeIds: [], resolvedUnknownNodeIds: [ids.complaintRateUnknown], affectedNodeIds: [], selectedQuestion: { nodeId: "n-commercial-value", question: "How should commercial value be defined for this decision?", reason: "This is the most consequential unresolved unknown introduced by the answer.", }, }; const result = applyValidatedProposal({ situationGraph: graph, proposal }); expect(result.success).toBe(true); expect(result.updatedSituationGraph.resolvedNodeIds).toContain( ids.complaintRateUnknown, ); expect( result.updatedSituationGraph.nodes.some( (node) => node.id === "n-commercial-value", ), ).toBe(true); expect( result.updatedSituationGraph.nodes.some( (node) => node.id === "n-demand-evidence", ), ).toBe(true); expect(result.newActiveUnknownNodeId).toBe("n-commercial-value"); expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value"); expect(result.selectedQuestion?.question).toMatch(/\?$/); expect(result.selectedQuestion?.question.length).toBeGreaterThan(20); expect(result.selectedQuestion?.question.toLowerCase()).not.toContain( "price", ); expect(result.selectedQuestion?.question.toLowerCase()).not.toContain( "how should uncertainty regarding", ); }); it("rejects more than 3 added unknowns", () => { const { graph, proposal, ids } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal: { ...proposal, addedNodes: [1, 2, 3, 4].map((index) => makeNode({ id: `n-unknown-${index}`, label: `Unknown ${index}`, description: `Need unknown ${index} because it matters to the decision.`, kind: "unknown", status: "unknown", confidence: "medium", }), ), addedEdges: [1, 2, 3, 4].map((index) => makeEdge({ id: `e-unknown-${index}`, fromNodeId: ids.complaintRateUnknown, toNodeId: `n-unknown-${index}`, relationship: "depends_on", confidence: "medium", description: `Links unknown ${index}`, }), ), selectedQuestion: { nodeId: "n-unknown-1", question: "What is unknown 1?", reason: "Follow-up required.", }, }, }); expect(result.success).toBe(false); expect(result.errors.join(" ")).toContain("too many unknown nodes"); }); it("rejects unrelated added unknowns", () => { const { graph, proposal } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal: { ...proposal, addedNodes: [ makeNode({ id: "n-unrelated", label: "Office rent", description: "Need office rent because it matters to a different branch.", kind: "unknown", status: "unknown", confidence: "low", }), ], selectedQuestion: { nodeId: "n-unrelated", question: "What is the office rent?", reason: "Unrelated test.", }, }, }); expect(result.success).toBe(false); expect(result.errors.join(" ")).toContain( "explicitly related to an answer-derived node", ); }); it("accepts a newly added unknown explicitly linked through answer-derived node fields", () => { const { graph, ids } = makeApplicationFixture(); const proposal = { addedNodes: [ makeNode({ id: "n-answer-context", label: "Build Confidence Engine decision", description: "Decision context introduced by the answer.", kind: "state", status: "supported", confidence: "medium", childIds: ["n-commercial-value"], }), makeNode({ id: "n-commercial-value", label: "Commercial value definition", description: "Need commercial value definition because the decision depends on it.", kind: "unknown", status: "unknown", confidence: "high", dependsOn: ["n-answer-context"], }), ], updatedNodes: [ { nodeId: ids.complaintRateUnknown, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Decision whether to build Confidence Engine", reason: "The answer resolves the original context unknown.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [ids.complaintRateUnknown], affectedNodeIds: [], selectedQuestion: { nodeId: "n-commercial-value", question: "How should commercial value be defined for this decision?", reason: "A consequential unknown remains unresolved.", }, }; const result = applyValidatedProposal({ situationGraph: graph, proposal }); expect(result.success).toBe(true); expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value"); }); it("rejects a newly added unknown linked only to the original unresolved node when that node is not answer-derived", () => { const { graph, ids } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal: { addedNodes: [ makeNode({ id: "n-commercial-value", label: "Commercial value definition", description: "Need commercial value definition because the decision depends on it.", kind: "unknown", status: "unknown", confidence: "high", dependsOn: [ids.complaintRateUnknown], }), ], updatedNodes: [], addedEdges: [ makeEdge({ id: "e-legacy-unknown-commercial-value", fromNodeId: ids.complaintRateUnknown, toNodeId: "n-commercial-value", relationship: "depends_on", confidence: "medium", description: "Links only to the original unresolved unknown.", }), ], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], selectedQuestion: { nodeId: "n-commercial-value", question: "How should commercial value be defined for this decision?", reason: "A consequential unknown remains unresolved.", }, }, }); expect(result.success).toBe(false); expect(result.errors.join(" ")).toContain( "explicitly related to an answer-derived node", ); }); it("rejects a floating emergent unknown with no explicit relationship", () => { const { graph } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal: { addedNodes: [ makeNode({ id: "n-floating", label: "Floating unknown", description: "Need this because it matters to the decision.", kind: "unknown", status: "unknown", confidence: "medium", }), ], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], selectedQuestion: { nodeId: "n-floating", question: "What would resolve Floating unknown?", reason: "Test case for floating unknown rejection.", }, }, }); expect(result.success).toBe(false); expect(result.errors.join(" ")).toContain( "explicitly related to an answer-derived node", ); }); it("accepts the reported live-shaped commercial-value proposal when the linkage is explicit in node references", () => { const { graph, ids } = makeApplicationFixture(); const proposal = { addedNodes: [ makeNode({ id: "answer_context_build", label: "Build Confidence Engine decision context", description: "The answer introduces a concrete decision about whether to build Confidence Engine.", kind: "state", status: "known", confidence: "high", dependsOn: [ids.complaintRateUnknown, "nu_commercial_val"], childIds: ["nu_commercial_val"], affects: ["nu_commercial_val"], }), makeNode({ id: "nu_commercial_val", label: "Commercial viability assessment of Confidence Engine", description: "The commercial viability of Confidence Engine remains unknown because resolving it is needed to decide whether building it is justified.", kind: "unknown", status: "unknown", confidence: "medium", dependsOn: ["answer_context_build"], childIds: ["answer_context_build"], }), ], updatedNodes: [ { nodeId: ids.complaintRateUnknown, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Deciding whether to build the Confidence Engine due to uncertainty about its commercial value.", reason: "The answer resolves the original context unknown.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [ids.complaintRateUnknown], affectedNodeIds: [ ids.complaintRateUnknown, "answer_context_build", "nu_commercial_val", ], selectedQuestion: { nodeId: "nu_commercial_val", question: "How should commercial viability be defined for this decision?", reason: "A foundational commercial-value unknown remains unresolved.", }, }; const result = applyValidatedProposal({ situationGraph: graph, proposal }); expect(result.success).toBe(true); expect(result.updatedSituationGraph.resolvedNodeIds).toContain( ids.complaintRateUnknown, ); expect( result.updatedSituationGraph.nodes.some( (node) => node.id === "nu_commercial_val", ), ).toBe(true); }); it("rejects selected question referencing resolved node", () => { const { graph, proposal, ids } = makeApplicationFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal: { ...proposal, selectedQuestion: { nodeId: ids.complaintRateUnknown, question: "What is the complaint rate?", reason: "Invalid reselection.", }, }, }); expect(result.success).toBe(false); expect(result.errors.join(" ")).toContain( "selectedQuestion must reference an unresolved node", ); }); it("active unknown matches selected question node", () => { const { graph, ids } = makeApplicationFixture(); const proposal = { addedNodes: [ makeNode({ id: "n-success-threshold", label: "Success threshold", description: "Need a success threshold because the decision depends on it.", kind: "unknown", status: "unknown", confidence: "high", }), makeNode({ id: "n-build-decision", label: "Build Confidence Engine decision", description: "Decision introduced by the answer.", kind: "state", status: "supported", confidence: "medium", }), ], updatedNodes: [ { nodeId: ids.complaintRateUnknown, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Decision whether to build Confidence Engine", reason: "The answer resolves the original unknown.", }, ], addedEdges: [ makeEdge({ id: "e-build-success-threshold", fromNodeId: "n-build-decision", toNodeId: "n-success-threshold", relationship: "depends_on", confidence: "medium", description: "The decision depends on a success threshold.", }), ], removedEdgeIds: [], resolvedUnknownNodeIds: [ids.complaintRateUnknown], affectedNodeIds: [], selectedQuestion: { nodeId: "n-success-threshold", question: "What success threshold would justify building it?", reason: "One consequential unknown remains.", }, }; const result = applyValidatedProposal({ situationGraph: graph, proposal }); expect(result.success).toBe(true); expect(result.newActiveUnknownNodeId).toBe(result.selectedQuestion?.nodeId); }); it("replaces downstream pricing question with higher-value commercial-value question", () => { const { graph, ids } = makeApplicationFixture(); const proposal = { addedNodes: [ makeNode({ id: "n-commercial-value", label: "Commercial value definition", description: "Need commercial value definition because the decision depends on it.", kind: "unknown", status: "unknown", confidence: "high", }), makeNode({ id: "n-pricing", label: "Target price point", description: "Need a price point because revenue assumptions depend on it.", kind: "unknown", status: "unknown", confidence: "medium", dependsOn: ["n-commercial-value"], }), makeNode({ id: "n-build-decision", label: "Build Confidence Engine decision", description: "Decision introduced by the answer.", kind: "state", status: "supported", confidence: "medium", }), ], updatedNodes: [ { nodeId: ids.complaintRateUnknown, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Decision whether to build Confidence Engine", reason: "The answer resolves the original context unknown.", }, ], addedEdges: [ makeEdge({ id: "e-build-commercial-value", fromNodeId: "n-build-decision", toNodeId: "n-commercial-value", relationship: "depends_on", confidence: "medium", description: "The decision depends on defining commercial value.", }), makeEdge({ id: "e-commercial-value-pricing", fromNodeId: "n-commercial-value", toNodeId: "n-pricing", relationship: "depends_on", confidence: "medium", description: "Pricing depends on commercial value definition.", }), makeEdge({ id: "e-build-pricing", fromNodeId: "n-build-decision", toNodeId: "n-pricing", relationship: "depends_on", confidence: "low", description: "The decision also references pricing assumptions.", }), ], removedEdgeIds: [], resolvedUnknownNodeIds: [ids.complaintRateUnknown], affectedNodeIds: [], selectedQuestion: { nodeId: "n-pricing", question: "What is the target price point?", reason: "Model chose a downstream leaf.", }, }; const result = applyValidatedProposal({ situationGraph: graph, proposal }); expect(result.success).toBe(true); expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value"); expect(result.selectedQuestion?.question.toLowerCase()).not.toContain( "price", ); }); it("resolves the existing comparability unknown and advances reasoning after the answer", () => { const { graph, proposal, comparabilityUnknownId } = makeComparabilityUpdateFixture(); const originalUnrelatedNode = JSON.stringify( graph.nodes.find((node) => node.id === "n-unrelated"), ); const result = applyValidatedProposal({ situationGraph: graph, proposal, previousQuestion: "Were these figures measured on the same basis and at the same scale?", answer: "Yes. Both figures cover the same accounting period and are taken from the same management accounts.", }); expect(result.success).toBe(true); expect(result.resolvedUnknownNodeIds).toContain(comparabilityUnknownId); expect(result.resolvedReasoningNodeIds).toEqual([ "reasoning:comparability", ]); expect(result.emergentReasoningNodeCreated).toBe(true); expect(result.emergentReasoningNodeId).toBeTruthy(); expect(result.emergentReasoningNodeReason).toContain("backed by the graph"); expect(result.previousReasoningState.comparabilityStatus).toBe("uncertain"); expect(result.reasoningState).toMatchObject({ comparabilityStatus: "confirmed", relationshipStatus: "potentially_related", relationshipAssessed: true, }); expect(result.reasoningState.comparabilityEvidence).toEqual([ comparabilityUnknownId, ]); expect(result.selectedQuestion?.nodeId).toBe(result.newActiveUnknownNodeId); expect(result.selectedQuestion).toMatchObject({ nodeId: result.newActiveUnknownNodeId, question: "What evidence would clarify how the two observations were measured?", }); expect(result.selectedQuestion?.question.toLowerCase()).not.toMatch( /dso|debtor days|receivables turnover|working capital|receivables/, ); expect(result.reasoningState.reasoningStages).toEqual([ { stage: "comparability", status: "confirmed", outcome: "Comparability was confirmed by the user answer covering the same period and source basis.", }, { stage: "relationship", status: "potentially_related", outcome: "The observations concern connected business signals but do not establish a direct contradiction or cause.", }, ]); const emergentNode = result.updatedSituationGraph.nodes.find( (node) => node.id === result.emergentReasoningNodeId, ); expect(emergentNode).toMatchObject({ kind: "unknown", status: "unknown", confidence: "medium", }); expect(emergentNode.description.toLowerCase()).toContain("because"); expect( result.updatedSituationGraph.edges.filter( (edge) => edge.toNodeId === result.emergentReasoningNodeId, ), ).not.toEqual([]); expect( result.updatedSituationGraph.edges.some( (edge) => edge.toNodeId === result.emergentReasoningNodeId && edge.relationship === "causes", ), ).toBe(false); expect( JSON.stringify( result.updatedSituationGraph.nodes.find( (node) => node.id === "n-unrelated", ), ), ).toBe(originalUnrelatedNode); }); it("reuses an equivalent existing unresolved reasoning unknown instead of creating a duplicate", () => { const { graph, proposal } = makeComparabilityUpdateFixture(); graph.nodes.push( makeNode({ id: "n-existing-explanation", label: "Explanation for why Revenue increased by 18%, but cash in the bank fell over the same period", description: "Need to understand what change or event could explain why these observations differ, because that is needed to investigate their relationship.", kind: "unknown", status: "unknown", confidence: "medium", }), ); const result = applyValidatedProposal({ situationGraph: graph, proposal, previousQuestion: "Were these figures measured on the same basis and at the same scale?", answer: "Yes. Both figures cover the same accounting period and are taken from the same management accounts.", }); expect(result.success).toBe(true); expect(result.emergentReasoningNodeCreated).toBe(false); expect(result.emergentReasoningNodeId).toBe("n-existing-explanation"); expect(result.newActiveUnknownNodeId).not.toBe("n-existing-explanation"); expect(result.selectedQuestion?.nodeId).not.toBe("n-existing-explanation"); expect(result.selectedQuestion?.question).toBe( "What evidence would clarify how the two observations were measured?", ); expect( result.updatedSituationGraph.nodes.filter( (node) => node.label === graph.nodes.at(-1).label, ), ).toHaveLength(1); }); it("decomposes a composite selected unknown before asking the next question", () => { const { graph, proposal } = makeComparabilityUpdateFixture(); const result = applyValidatedProposal({ situationGraph: graph, proposal, previousQuestion: "Were these figures measured on the same basis and at the same scale?", answer: "Yes. Both figures cover the same accounting period and are taken from the same management accounts.", }); expect(result.success).toBe(true); expect(result.atomicityAssessment).toBe("composite"); expect(result.decompositionPerformed).toBe(true); expect(result.childUnknownCount).toBe(5); expect(result.childNodeIds).toHaveLength(5); expect(result.atomicityReason).toBeTruthy(); expect(result.selectedQuestion?.nodeId).toBe(result.newActiveUnknownNodeId); expect(result.selectedQuestion?.nodeId).not.toBe( result.emergentReasoningNodeId, ); expect(result.selectedQuestion?.question.toLowerCase()).not.toMatch( /dso|working capital|receivables|capex/, ); const parentNode = result.updatedSituationGraph.nodes.find( (node) => node.id === result.emergentReasoningNodeId, ); expect(parentNode?.status).toBe("unknown"); const childNodes = result.updatedSituationGraph.nodes.filter((node) => result.childNodeIds.includes(node.id), ); expect(childNodes).toHaveLength(5); expect(childNodes.every((node) => node.parentId === parentNode.id)).toBe( true, ); expect( result.updatedSituationGraph.edges.filter( (edge) => result.childNodeIds.includes(edge.fromNodeId) && edge.toNodeId === parentNode.id && edge.relationship === "depends_on", ), ).toHaveLength(5); }); it("reuses existing decomposition children instead of duplicating them", () => { const { graph, proposal } = makeComparabilityUpdateFixture(); const firstResult = applyValidatedProposal({ situationGraph: graph, proposal, previousQuestion: "Were these figures measured on the same basis and at the same scale?", answer: "Yes. Both figures cover the same accounting period and are taken from the same management accounts.", }); expect(firstResult.success).toBe(true); const secondResult = applyValidatedProposal({ situationGraph: graph, proposal, previousQuestion: "Were these figures measured on the same basis and at the same scale?", answer: "Yes. Both figures cover the same accounting period and are taken from the same management accounts.", }); expect(secondResult.success).toBe(true); expect(secondResult.atomicityAssessment).toBe("composite"); const uniqueChildIds = new Set(firstResult.childNodeIds); expect(uniqueChildIds.size).toBe(firstResult.childNodeIds.length); expect( secondResult.updatedSituationGraph.nodes.filter((node) => firstResult.childNodeIds.includes(node.id), ), ).toHaveLength(firstResult.childNodeIds.length); }); });