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 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).toContain( "Commercial value definition", ); }); 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("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", ); }); });