import { describe, it, expect } from "vitest"; import { validateGraphReferences, detectDuplicateNodeIds, detectDuplicateEdges, scoreUnknownCandidate, findDependentNodes, findAffectedNodes, resolveUnknownNode, selectActiveUnknownCandidate, applyGraphUpdate, validateGraphUpdate, } from "@/lib/graph/utils.js"; import { makeNode, makeEdge, makeGraph } from "@/lib/graph/schema.js"; // ── Helper: build a minimal graph for tests ─────────── function makeTestGraph() { const n1 = makeNode({ id: "n1", label: "Actor A" }); const n2 = makeNode({ id: "n2", label: "State B" }); const n3 = makeNode({ id: "n3", label: "Transition C" }); const n4 = makeNode({ id: "n4", label: "Unknown D" }); const n5 = makeNode({ id: "n5", label: "Unknown E" }); // n2 depends on n1; n3 depends on n2 (transitive depends on n1) n2.dependsOn.push(n1.id); n3.dependsOn.push(n2.id); // n4 is an unknown not depended on // n5 is an unknown depended upon by n3 indirectly const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "depends_on", }); const e2 = makeEdge({ id: "e2", fromNodeId: n3.id, toNodeId: n1.id, relationship: "supports", }); return makeGraph({ centralStatement: "Test graph", nodes: [n1, n2, n3, n4, n5], edges: [e1, e2], activeUnknownNodeId: n4.id, resolvedNodeIds: [], currentSummary: "Test", }); } describe("validateGraphReferences", () => { it("accepts valid graph with all self-consistent references", () => { const graph = makeTestGraph(); const result = validateGraphReferences(graph); expect(result.valid).toBe(true); expect(result.errors.length).toBe(0); }); it("detects invalid parentId reference", () => { const graph = makeTestGraph(); // n1 has no parentId, so this won't trigger; let's add one manually graph.nodes[0].parentId = "nonexistent-parent"; const result = validateGraphReferences(graph); expect(result.valid).toBe(false); expect(result.errors.some((e) => e.includes("nonexistent-parent"))).toBe( true, ); }); it("detects invalid childIds reference", () => { const graph = makeTestGraph(); graph.nodes[0].childIds.push("ghost-node"); const result = validateGraphReferences(graph); expect(result.valid).toBe(false); }); it("detects invalid dependsOn reference", () => { const graph = makeTestGraph(); graph.nodes[0].dependsOn.push("phantom-dep"); const result = validateGraphReferences(graph); expect(result.valid).toBe(false); }); it("detects invalid affects reference", () => { const graph = makeTestGraph(); graph.nodes[0].affects.push("void-node"); const result = validateGraphReferences(graph); expect(result.valid).toBe(false); }); it("detects edge referencing non-existent fromNodeId", () => { const graph = makeTestGraph(); graph.edges[0].fromNodeId = "ghost-node"; const result = validateGraphReferences(graph); expect(result.valid).toBe(false); expect(result.errors.some((e) => e.includes("ghost-node"))).toBe(true); }); it("detects edge referencing non-existent toNodeId", () => { const graph = makeTestGraph(); graph.edges[0].toNodeId = "void-node"; const result = validateGraphReferences(graph); expect(result.valid).toBe(false); }); it("allows mixed valid and invalid references", () => { const graph = makeTestGraph(); graph.nodes[0].parentId = "missing"; graph.nodes[1].parentId = "also-missing"; const result = validateGraphReferences(graph); expect(result.valid).toBe(false); expect(result.errors.length).toBe(2); }); }); describe("detectDuplicateNodeIds", () => { it("returns empty for unique nodes", () => { const graph = makeTestGraph(); const dups = detectDuplicateNodeIds(graph.nodes); expect(dups.length).toBe(0); }); it("detects exact duplicate IDs", () => { const n1 = makeNode({ id: "dup", label: "First" }); const n2 = makeNode({ id: "dup", label: "Second" }); const dups = detectDuplicateNodeIds([n1, n2]); expect(dups.length).toBe(1); expect(dups[0].nodeId).toBe("dup"); expect(dups[0].count).toBe(2); }); it("detects multiple duplicate groups", () => { const nodes = [ makeNode({ id: "dup", label: "A" }), makeNode({ id: "dup", label: "B" }), makeNode({ id: "dup", label: "C" }), makeNode({ id: "dup2", label: "D" }), makeNode({ id: "dup2", label: "E" }), ]; const dups = detectDuplicateNodeIds(nodes); expect(dups.length).toBe(2); }); it("reports correct count for triple duplicates", () => { const nodes = [ makeNode({ id: "trip", label: "1" }), makeNode({ id: "trip", label: "2" }), makeNode({ id: "trip", label: "3" }), ]; const dups = detectDuplicateNodeIds(nodes); expect(dups[0].count).toBe(3); }); }); describe("detectDuplicateEdges", () => { it("returns empty for unique edges", () => { const graph = makeTestGraph(); const dups = detectDuplicateEdges(graph.edges); expect(dups.length).toBe(0); }); it("detects duplicate edge (same from, to, relationship)", () => { const n1 = makeNode({ id: "n1", label: "A" }); const n2 = makeNode({ id: "n2", label: "B" }); const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports", }); const e2 = makeEdge({ id: "e2", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports", }); const dups = detectDuplicateEdges([e1, e2]); expect(dups.length).toBe(1); }); it("allows same nodes with different relationship types", () => { const n1 = makeNode({ id: "n1", label: "A" }); const n2 = makeNode({ id: "n2", label: "B" }); const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports", }); const e2 = makeEdge({ id: "e2", fromNodeId: n1.id, toNodeId: n2.id, relationship: "weakens", }); const dups = detectDuplicateEdges([e1, e2]); expect(dups.length).toBe(0); }); it("detects reversed direction as different edge", () => { const n1 = makeNode({ id: "n1", label: "A" }); const n2 = makeNode({ id: "n2", label: "B" }); const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports", }); const e2 = makeEdge({ id: "e2", fromNodeId: n2.id, toNodeId: n1.id, relationship: "supports", }); const dups = detectDuplicateEdges([e1, e2]); expect(dups.length).toBe(0); }); }); describe("findDependentNodes (transitive)", () => { it("returns empty for node with no dependents", () => { const graph = makeTestGraph(); // n5 has nothing depending on it const deps = findDependentNodes(graph, "n5"); expect(deps.length).toBe(0); }); it("finds direct dependents via dependsOn", () => { const graph = makeTestGraph(); // n2 depends on n1 const deps = findDependentNodes(graph, "n1"); expect(deps).toContain("n2"); }); it("finds transitive dependents via dependsOn chain", () => { const graph = makeTestGraph(); // n3 depends on n2 depends on n1 — so both n2 and n3 depend on n1 const deps = findDependentNodes(graph, "n1"); expect(deps).toContain("n2"); expect(deps).toContain("n3"); }); it("finds dependents via edge relationship too", () => { const graph = makeTestGraph(); // e2: n3 -> n1 (supports), so if we query for nodes depending on n1 // the function also looks at edges where toNodeId === queriedId const deps = findDependentNodes(graph, "n1"); expect(deps).toContain("n2"); }); it("returns self if node depends on itself", () => { const graph = makeTestGraph(); graph.nodes[0].dependsOn.push("n1"); // n1 depends on n1 (circular) const deps = findDependentNodes(graph, "n1"); expect(deps).toContain("n1"); }); it("handles deep dependency chains", () => { const nodes = []; for (let i = 1; i <= 10; i++) { nodes.push(makeNode({ id: `n${i}`, label: `N${i}` })); } // Chain: n2 depends on n1, n3 depends on n2, ..., n10 depends on n9 for (let i = 2; i <= 10; i++) { nodes[i - 1].dependsOn.push(nodes[0].id); // All depend on n1 } const graph = makeGraph({ centralStatement: "Chain", nodes, edges: [], activeUnknownNodeId: null, resolvedNodeIds: [], currentSummary: "Test", }); const deps = findDependentNodes(graph, "n1"); expect(deps.length).toBe(9); // All other nodes depend on n1 }); }); describe("findAffectedNodes (transitive)", () => { it("returns empty for node that affects nothing", () => { const graph = makeTestGraph(); const affected = findAffectedNodes(graph, "n5"); expect(affected.length).toBe(0); }); it("finds nodes listed in affects array", () => { // Set up: n2 has n3 in its affects list const graph = makeTestGraph(); graph.nodes[1].affects.push("n3"); const affected = findAffectedNodes(graph, "n2"); expect(affected).toContain("n3"); }); it("propagates through dependsOn transitive chain", () => { // n3 depends on n2, and n2's affects includes some node that depends on n3 const graph = makeTestGraph(); // If n2 is changed and n3 depends on n2, then n3 should be affected graph.nodes[2].dependsOn.push("n2"); // Explicit dependency const affected = findAffectedNodes(graph, "n2"); expect(affected).toContain("n3"); }); it("handles empty graph", () => { // build a minimal graph without triggering schema validation for this edge case const graph = { centralStatement: "Empty", nodes: [], edges: [], resolvedNodeIds: [], currentSummary: "", activeUnknownNodeId: null, }; const affected = findAffectedNodes(graph, "any-node"); expect(affected.length).toBe(0); }); }); describe("resolveUnknownNode", () => { it("returns success for valid node id", () => { const graph = makeTestGraph(); const result = resolveUnknownNode( graph, "n4", "resolved", "Confirmed", "User confirmed", ); expect(result.success).toBe(true); expect(result.newStatus).toBe("resolved"); expect(result.reason).toBe("User confirmed"); }); it("returns error for non-existent node", () => { const graph = makeTestGraph(); const result = resolveUnknownNode( graph, "ghost-node", "resolved", null, "reason", ); expect(result.success).toBe(false); expect(result.error).toContain("not found"); }); it("reports affectedNodes in result", () => { const graph = makeTestGraph(); // n5 depends on... actually let's set up properly graph.nodes[3].affects.push("n1"); // Unknown depends on Actor A graph.nodes[3].dependsOn.push("n2"); // Unknown depends on State B const result = resolveUnknownNode( graph, "n4", "resolved", "Yes", "Clarified", ); expect(result.success).toBe(true); }); it("tracks previous status and value", () => { const graph = makeTestGraph(); const result = resolveUnknownNode( graph, "n4", "known", "confirmed_value", "Evidence found", ); expect(result.previousStatus).toBe("unknown"); expect(result.newValue).toBe("confirmed_value"); }); }); describe("selectActiveUnknownCandidate", () => { it("returns null when no unresolved unknowns", () => { // makeTestGraph nodes default to kind "observation", not "unknown" // Create explicit unknown-kind nodes for this test const nUnknown = makeNode({ id: "n-unk-x", label: "Unknown X", kind: "unknown", }); const graph = makeGraph({ centralStatement: "Test", nodes: [nUnknown], edges: [], activeUnknownNodeId: null, resolvedNodeIds: [], currentSummary: "Test", }); // Mark it as resolved so no unresolved unknowns remain const result = selectActiveUnknownCandidate(graph, ["n-unk-x"]); expect(result).toBeNull(); }); it("skips already-resolved nodes and returns remaining unknown", () => { const n1 = makeNode({ id: "n1", label: "A", kind: "observation" }); const n2 = makeNode({ id: "n-unk-b", label: "Unknown B", kind: "unknown" }); const graph = makeGraph({ centralStatement: "Test", nodes: [n1, n2], edges: [], activeUnknownNodeId: null, resolvedNodeIds: [], currentSummary: "Test", }); // Skip n2 by passing it as resolved; no unknown-kind nodes remain const result = selectActiveUnknownCandidate(graph, ["n-unk-b"]); expect(result).toBeNull(); // Without skipping, should return n2 const result2 = selectActiveUnknownCandidate(graph, []); expect(result2.nodeId).toBe("n-unk-b"); }); it("prioritises nodes with more dependents", () => { const unknownA = makeNode({ id: "unknown-a", label: "Unknown A", kind: "unknown", }); const unknownB = makeNode({ id: "unknown-b", label: "Unknown B", kind: "unknown", }); const dependent = makeNode({ id: "dep", label: "Dependent", kind: "state", }); dependent.dependsOn.push("unknown-a"); const graph = makeGraph({ centralStatement: "Priority test", nodes: [unknownA, unknownB, dependent], edges: [], activeUnknownNodeId: null, resolvedNodeIds: [], currentSummary: "Test", }); const result = selectActiveUnknownCandidate(graph, []); expect(result.nodeId).toBe("unknown-a"); // Has more dependents (score 2 vs 0) expect(result.status).toBe("selected"); }); it("returns one candidate (not array)", () => { const n1 = makeNode({ id: "n1", label: "A", kind: "observation" }); const nUnknown = makeNode({ id: "n-unk", label: "Pending", kind: "unknown", }); const graph = makeGraph({ centralStatement: "Test", nodes: [n1, nUnknown], edges: [], activeUnknownNodeId: null, resolvedNodeIds: [], currentSummary: "Test", }); const result = selectActiveUnknownCandidate(graph, []); expect(typeof result).toBe("object"); expect(result.nodeId).toBeDefined(); expect(result.label).toBeDefined(); expect(result.score).toBeDefined(); }); it("commercial value wins over pricing", () => { const commercialValue = makeNode({ id: "n-commercial-value", label: "Commercial value definition", description: "Need to define commercial value because the decision depends on it.", kind: "unknown", status: "unknown", confidence: "high", }); const pricing = makeNode({ id: "n-pricing", label: "Target price point", description: "Need a target price point because revenue assumptions depend on it.", kind: "unknown", status: "unknown", confidence: "medium", dependsOn: ["n-commercial-value"], }); const decision = makeNode({ id: "n-decision", label: "Build decision", description: "Decision context", kind: "state", status: "supported", confidence: "medium", dependsOn: ["n-commercial-value", "n-pricing"], }); const graph = makeGraph({ centralStatement: "Build decision", nodes: [commercialValue, pricing, decision], edges: [], activeUnknownNodeId: pricing.id, resolvedNodeIds: [], currentSummary: "Test", }); const result = selectActiveUnknownCandidate(graph, []); expect(result.nodeId).toBe("n-commercial-value"); }); it("customer value wins over UI colour", () => { const customerValue = makeNode({ id: "n-customer-value", label: "Customer value", description: "Need to know customer value because adoption depends on it.", kind: "unknown", status: "unknown", confidence: "high", }); const uiColour = makeNode({ id: "n-ui-colour", label: "UI colour", description: "Need a UI colour because presentation choices remain open.", kind: "unknown", status: "unknown", confidence: "low", }); const graph = makeGraph({ centralStatement: "Value question", nodes: [customerValue, uiColour], edges: [], activeUnknownNodeId: null, resolvedNodeIds: [], currentSummary: "Test", }); const result = selectActiveUnknownCandidate(graph, []); expect(result.nodeId).toBe("n-customer-value"); }); it("success criteria wins over marketing slogan", () => { const successCriteria = makeNode({ id: "n-success-criteria", label: "Success criteria", description: "Need success criteria because the decision requires a threshold.", kind: "unknown", status: "unknown", confidence: "high", }); const slogan = makeNode({ id: "n-slogan", label: "Marketing slogan", description: "Need a slogan because messaging is undecided.", kind: "unknown", status: "unknown", confidence: "low", }); const graph = makeGraph({ centralStatement: "Threshold question", nodes: [successCriteria, slogan], edges: [], activeUnknownNodeId: null, resolvedNodeIds: [], currentSummary: "Test", }); const result = selectActiveUnknownCandidate(graph, []); expect(result.nodeId).toBe("n-success-criteria"); }); it("penalises unknowns with unresolved parent unknowns", () => { const parentUnknown = makeNode({ id: "n-parent", label: "Commercial value definition", description: "Need commercial value definition because the decision depends on it.", kind: "unknown", status: "unknown", confidence: "high", }); const childUnknown = makeNode({ id: "n-child", label: "Target price point", description: "Need price point because revenue assumptions depend on it.", kind: "unknown", status: "unknown", confidence: "medium", dependsOn: ["n-parent"], }); const graph = makeGraph({ centralStatement: "Dependency ordering", nodes: [parentUnknown, childUnknown], edges: [], activeUnknownNodeId: null, resolvedNodeIds: [], currentSummary: "Test", }); const parentScore = scoreUnknownCandidate(graph, parentUnknown, []); const childScore = scoreUnknownCandidate(graph, childUnknown, []); expect(parentScore.score).toBeGreaterThan(childScore.score); }); it("returns ambiguous for a complete unresolved tie instead of label-based winner", () => { const unknownA = makeNode({ id: "tie-a", label: "Magnitude and nature of cash outflows", description: "Magnitude and nature of cash outflows.", kind: "unknown", status: "unknown", confidence: "high", }); const unknownB = makeNode({ id: "tie-b", label: "Whether revenue recognition timing differs from cash collection timing", description: "Whether revenue recognition timing differs from cash collection timing.", kind: "unknown", status: "unknown", confidence: "high", }); const graph = makeGraph({ centralStatement: "Revenue increased by 18%, but cash in the bank fell over the same period.", nodes: [unknownA, unknownB], edges: [], activeUnknownNodeId: null, resolvedNodeIds: [], currentSummary: "Tie case", }); const result = selectActiveUnknownCandidate(graph, []); expect(result).toMatchObject({ selectedNode: null, status: "ambiguous", tieType: "complete_unresolved_tie", tiedCandidateIds: ["tie-a", "tie-b"], }); expect(result.nodeId).toBeUndefined(); }); it("alphabetical renaming does not resolve a complete tie", () => { const unknownA = makeNode({ id: "tie-a", label: "Unknown B", description: "Unknown factor one.", kind: "unknown", }); const unknownB = makeNode({ id: "tie-b", label: "Unknown A", description: "Unknown factor two.", kind: "unknown", }); const graph = makeGraph({ centralStatement: "Two conflicting signals remain unresolved.", nodes: [unknownA, unknownB], edges: [], activeUnknownNodeId: null, resolvedNodeIds: [], currentSummary: "Tie case", }); const result = selectActiveUnknownCandidate(graph, []); expect(result.status).toBe("ambiguous"); expect(result.tiedCandidateIds.sort()).toEqual(["tie-a", "tie-b"]); }); }); describe("applyGraphUpdate", () => { it("applies node additions correctly", () => { const graph = makeTestGraph(); const newNode = makeNode({ id: "n-new", label: "New Node" }); const update = { addedNodes: [newNode], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }; const result = applyGraphUpdate(graph, update); expect(result.success).toBe(true); expect(result.nodes.length).toBe(graph.nodes.length + 1); expect(result.nodes.some((n) => n.id === "n-new")).toBe(true); }); it("applies status updates correctly", () => { const graph = makeTestGraph(); const update = { addedNodes: [], updatedNodes: [ { nodeId: "n4", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "confirmed", reason: "Answered by user", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: ["n4"], affectedNodeIds: [], }; const result = applyGraphUpdate(graph, update); expect(result.success).toBe(true); const updatedNode = result.nodes.find((n) => n.id === "n4"); expect(updatedNode.status).toBe("resolved"); }); it("rejects update with non-existent nodeId in updatedNodes", () => { const graph = makeTestGraph(); const update = { addedNodes: [], updatedNodes: [ { nodeId: "ghost-node", previousStatus: null, newStatus: "known", reason: "test", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }; const result = applyGraphUpdate(graph, update); expect(result.success).toBe(false); expect(result.errors.some((e) => e.includes("ghost-node"))).toBe(true); }); it("removes requested edges", () => { const graph = makeTestGraph(); const edgeIdToRemove = graph.edges[0].id; const update = { addedNodes: [], updatedNodes: [], addedEdges: [], removedEdgeIds: [edgeIdToRemove], resolvedUnknownNodeIds: [], affectedNodeIds: [], }; const result = applyGraphUpdate(graph, update); expect(result.success).toBe(true); expect(result.edges.length).toBe(graph.edges.length - 1); expect(result.edges.some((e) => e.id === edgeIdToRemove)).toBe(false); }); it("adds edges and updates node dependsOn/affects", () => { const graph = makeTestGraph(); const newEdge = makeEdge({ fromNodeId: "n1", toNodeId: "n4", relationship: "supports", }); const update = { addedNodes: [], updatedNodes: [], addedEdges: [newEdge], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }; const result = applyGraphUpdate(graph, update); expect(result.success).toBe(true); // Check the edge was added expect(result.edges.some((e) => e.id === newEdge.id)).toBe(true); // Check node relationship arrays updated const fromNode = result.nodes.find((n) => n.id === "n1"); const toNode = result.nodes.find((n) => n.id === "n4"); expect(fromNode.childIds).toContain("n4"); expect(toNode.dependsOn).toContain("n1"); }); it("accumulates resolved node IDs", () => { const graph = makeTestGraph(); const update = { addedNodes: [], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: ["n4"], affectedNodeIds: [], }; const result = applyGraphUpdate(graph, update); expect(result.success).toBe(true); expect(result.resolvedNodeIds).toContain("n4"); }); it("rejects adding duplicate node IDs", () => { const graph = makeTestGraph(); const existingNode = graph.nodes[0]; // id: "n1" // Use the exact same ID as an existing node to create a real duplicate const update = { addedNodes: [{ ...existingNode, id: "n1", label: "Dup Node" }], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }; const result = applyGraphUpdate(graph, update); expect(result.success).toBe(false); }); it("rejects edges referencing non-existent nodes", () => { const graph = makeTestGraph(); const update = { addedNodes: [], updatedNodes: [], addedEdges: [ { id: "e-new", fromNodeId: "missing-node", toNodeId: "n1", relationship: "supports", confidence: "medium", description: "bad edge", }, ], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }; const result = applyGraphUpdate(graph, update); expect(result.success).toBe(false); }); it("preserves nodes not mentioned in the update", () => { const graph = makeTestGraph(); const unchangedCount = graph.nodes.length; const update = { addedNodes: [], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }; const result = applyGraphUpdate(graph, update); expect(result.success).toBe(true); expect(result.nodes.length).toBe(unchangedCount); }); it("applies multiple operations in one update", () => { const graph = makeTestGraph(); const newNode = makeNode({ id: "n-multi", label: "Multi" }); const update = { addedNodes: [newNode], updatedNodes: [ { nodeId: "n4", previousStatus: "unknown", newStatus: "resolved", reason: "Multiple ops test", }, ], addedEdges: [makeEdge({ fromNodeId: "n-multi", toNodeId: "n1" })], removedEdgeIds: [graph.edges[0]?.id || ""], resolvedUnknownNodeIds: ["n4"], affectedNodeIds: [], }; const result = applyGraphUpdate(graph, update); expect(result.success).toBe(true); }); }); describe("selectActiveUnknownCandidate deterministic tie resolution", () => { it("returns a stable non-null winner when two eligible candidates tie on top score", () => { const decision = makeNode({ id: "n_decision_tie", label: "Which option leaves us better off overall?", description: "Decision context", kind: "unknown", status: "unknown", confidence: "medium", }); const launch = makeNode({ id: "opt_launch_tie", label: "Launch this year", description: "Launch this year.", kind: "option", status: "known", confidence: "high", }); const timeline = makeNode({ id: "n_customer_decision_timeline", label: "When will the enterprise customer make its decision?", description: "Unknown when the enterprise customer will make its decision, and the timing could still affect launch planning.", kind: "unknown", status: "unknown", confidence: "medium", }); const viability = makeNode({ id: "n_launch_viability_without_anchor_customer", label: "Is launch commercially viable without the £700,000?", description: "Unknown whether launching this year is still commercially viable without the £700,000 enterprise-customer revenue.", kind: "unknown", status: "unknown", confidence: "medium", }); const competitor = makeNode({ id: "n_competitor_move_first_risk", label: "Could competitors move first during a 12-month delay?", description: "Unknown whether competitors could move first if launch is delayed by twelve months.", kind: "unknown", status: "unknown", confidence: "medium", }); const graph = makeGraph({ centralStatement: "Launch decision", nodes: [decision, launch, timeline, viability, competitor], edges: [ makeEdge({ fromNodeId: launch.id, toNodeId: decision.id, relationship: "contained_in", }), makeEdge({ fromNodeId: timeline.id, toNodeId: launch.id, relationship: "depends_on", }), makeEdge({ fromNodeId: viability.id, toNodeId: decision.id, relationship: "depends_on", }), makeEdge({ fromNodeId: competitor.id, toNodeId: launch.id, relationship: "contained_in", }), ], activeUnknownNodeId: null, resolvedNodeIds: [], currentSummary: "Tie case", }); const first = selectActiveUnknownCandidate(graph, []); const second = selectActiveUnknownCandidate(graph, []); expect(first?.status).toBe("selected"); expect(first?.nodeId).toBeTruthy(); expect(second?.status).toBe("selected"); expect(second?.nodeId).toBe(first?.nodeId); }); }); describe("validateGraphUpdate", () => { it("accepts a no-op update with added nodes", () => { const graph = makeTestGraph(); const newNode = makeNode({ id: "n-new", label: "New" }); const result = validateGraphUpdate(graph, { addedNodes: [newNode], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }); expect(result.valid).toBe(true); }); it("rejects update with no meaningful change", () => { const graph = makeTestGraph(); const result = validateGraphUpdate(graph, { addedNodes: [], updatedNodes: [ { nodeId: "n1", previousStatus: null, newStatus: null, previousValue: null, newValue: null, reason: "No change test", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }); expect(result.valid).toBe(false); expect(result.errors.some((e) => e.includes("no meaningful"))).toBe(true); }); it("rejects duplicate node IDs in additions", () => { const graph = makeTestGraph(); const existingNode = graph.nodes[0]; const result = validateGraphUpdate(graph, { addedNodes: [existingNode], // Duplicate ID updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }); expect(result.valid).toBe(false); }); it("rejects update to non-existent node", () => { const graph = makeTestGraph(); const result = validateGraphUpdate(graph, { addedNodes: [], updatedNodes: [ { nodeId: "ghost-node", previousStatus: null, newStatus: "known", reason: "test", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }); expect(result.valid).toBe(false); }); it("accepts valid status change as meaningful", () => { const graph = makeTestGraph(); const result = validateGraphUpdate(graph, { addedNodes: [], updatedNodes: [ { nodeId: "n4", previousStatus: "unknown", newStatus: "known", reason: "Confirmed", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }); expect(result.valid).toBe(true); }); it("rejects oversized update (>100KB)", () => { const graph = makeTestGraph(); const largeDescription = "x".repeat(150000); const result = validateGraphUpdate(graph, { addedNodes: [{ label: largeDescription }], // Will create huge JSON updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }); expect(result.valid).toBe(false); expect( result.errors.some((e) => e.includes("100KB") || e.includes("exceeds")), ).toBe(true); }); it("returns empty errors array for valid update", () => { const graph = makeTestGraph(); const result = validateGraphUpdate(graph, { addedNodes: [makeNode({ id: "n-valid", label: "Valid" })], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }); expect(result.valid).toBe(true); expect(result.errors.length).toBe(0); }); }); // ── Structural action contract v0.23 (57J.70) ──────── describe("structural-action-contract-v0.23", () => { // Test 1 — true + zero mutation + populated meaning → exactly one structuralActionRequired error it("true + zero mutation + populated meaning: exactly one contract error, no legacy duplicate", () => { const graph = makeTestGraph(); const update = { addedNodes: [], updatedNodes: [ { nodeId: "n1", previousStatus: null, newStatus: null, previousValue: null, newValue: null, reason: "test" }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], answerMeaning: { userSupportedMeaning: "User is unsure about savings realism.", possibleInference: null }, structuralActionRequired: true, }; const result = validateGraphUpdate(graph, update); expect(result.valid).toBe(false); expect(result.errors.length).toBe(1); expect(result.errors[0]).toBe("structuralActionRequired is true but proposal contains no graph mutation"); }); // Test 2 — false + zero mutation + populated meaning → pass (intentional no-op) it("false + zero mutation + populated meaning: pass (intentional no-op)", () => { const graph = makeTestGraph(); const update = { addedNodes: [], updatedNodes: [ { nodeId: "n1", previousStatus: null, newStatus: null, previousValue: null, newValue: null, reason: "test" }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], answerMeaning: { userSupportedMeaning: "No structural change needed.", possibleInference: null }, structuralActionRequired: false, }; const result = validateGraphUpdate(graph, update); expect(result.valid).toBe(true); expect(result.errors.length).toBe(0); }); // Test 3 — true + meaningful mutation → pass it("true + meaningful mutation: pass", () => { const graph = makeTestGraph(); const update = { addedNodes: [makeNode({ id: "n-new-unknown", label: "New unknown" })], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], answerMeaning: { userSupportedMeaning: "Evidence needed.", possibleInference: null }, structuralActionRequired: true, }; const result = validateGraphUpdate(graph, update); expect(result.valid).toBe(true); expect(result.errors.length).toBe(0); }); // Test 4 — false + meaningful mutation → exactly one contradiction error it("false + meaningful mutation: exactly one contradiction error", () => { const graph = makeTestGraph(); const update = { addedNodes: [makeNode({ id: "n-new-unknown2", label: "New unknown" })], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], answerMeaning: { userSupportedMeaning: "Evidence needed.", possibleInference: null }, structuralActionRequired: false, }; const result = validateGraphUpdate(graph, update); expect(result.valid).toBe(false); expect(result.errors.length).toBe(1); expect(result.errors[0]).toBe("structuralActionRequired is false but proposal contains meaningful mutations"); }); // Test 5 — missing/null + populated meaning → existing specific missing-field rejection it("null + populated meaning: existing transition rule rejection preserved", () => { const graph = makeTestGraph(); const update = { addedNodes: [makeNode({ id: "n-new-unknown3", label: "New unknown" })], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], answerMeaning: { userSupportedMeaning: "Evidence needed.", possibleInference: null }, }; const result = validateGraphUpdate(graph, update); expect(result.valid).toBe(false); expect(result.errors.some((e) => e.includes("structuralActionRequired must be present"))).toBe(true); }); // Test 6 — legacy no-meaning no-op preserved it("null field + no meaning + zero mutation: existing legacy no-op rejection", () => { const graph = makeTestGraph(); const update = { addedNodes: [], updatedNodes: [ { nodeId: "n1", previousStatus: null, newStatus: null, previousValue: null, newValue: null, reason: "test" }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], answerMeaning: null, }; const result = validateGraphUpdate(graph, update); expect(result.valid).toBe(false); expect(result.errors.some((e) => e === "Update contains no meaningful change")).toBe(true); }); // Test 7 — hasMeaningfulChange definition unchanged it("hasMeaningfulChange: null + no meaning + status change still counts as meaningful", () => { const graph = makeTestGraph(); const update = { addedNodes: [], updatedNodes: [ { nodeId: "n4", previousStatus: "unknown", newStatus: "known", reason: "confirmed" }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], answerMeaning: null, }; const result = validateGraphUpdate(graph, update); expect(result.valid).toBe(true); expect(result.errors.length).toBe(0); }); // Test 8 — no schema change required (import unchanged) it("schema imports unchanged", () => { // This test verifies structural integrity: the makeNode/makeEdge helpers used above // come from the same schema module. If schema shape changed, these would fail to compile/resolve. const graph = makeTestGraph(); expect(graph.nodes.length).toBe(5); }); // Test 9 — no prompt change required (validator-level fix only) it("validator-level fix does not affect apply path", () => { const graph = makeTestGraph(); const newNode = makeNode({ id: "n-valid-apply", label: "Valid" }); const updateResult = validateGraphUpdate(graph, { addedNodes: [newNode], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], }); expect(updateResult.valid).toBe(true); }); // Test 10 — no semantic keyword logic added (pure boolean + structural checks) it("true/false gating: purely boolean with structural check, no semantics", () => { const graph = makeTestGraph(); // true without mutation → error (structural only) let result = validateGraphUpdate(graph, { addedNodes: [], updatedNodes: [{ nodeId: "n1", previousStatus: null, newStatus: null, previousValue: null, newValue: null, reason: "t" }], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], structuralActionRequired: true, }); expect(result.errors.length).toBe(1); // false with mutation → error (structural only) result = validateGraphUpdate(graph, { addedNodes: [makeNode({ id: "n-k", label: "New unknown" })], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], structuralActionRequired: false, }); expect(result.errors.length).toBe(1); // true with mutation → pass (structural only) result = validateGraphUpdate(graph, { addedNodes: [makeNode({ id: "n-l", label: "New unknown" })], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], structuralActionRequired: true, }); expect(result.valid).toBe(true); // false without mutation → pass (structural only) result = validateGraphUpdate(graph, { addedNodes: [], updatedNodes: [{ nodeId: "n1", previousStatus: null, newStatus: null, previousValue: null, newValue: null, reason: "t" }], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], structuralActionRequired: false, }); expect(result.valid).toBe(true); }); }); // ── Integration: full update lifecycle ─────────────────── describe("update lifecycle integration", () => { it("complete update cycle: validate → apply → verify", () => { const graph = makeTestGraph(); // Create a meaningful update const newNode = makeNode({ id: "n-new", label: "New Discovery" }); const newEdge = makeEdge({ fromNodeId: "n1", toNodeId: "n-new", relationship: "supports", }); // Validate first const validationResult = validateGraphUpdate(graph, { addedNodes: [newNode], updatedNodes: [ { nodeId: "n4", previousStatus: "unknown", newStatus: "resolved", reason: "Answered via follow-up question", }, ], addedEdges: [newEdge], removedEdgeIds: [], resolvedUnknownNodeIds: ["n4"], affectedNodeIds: [], }); expect(validationResult.valid).toBe(true); // Apply const applyResult = applyGraphUpdate(graph, { addedNodes: [newNode], updatedNodes: [ { nodeId: "n4", previousStatus: "unknown", newStatus: "resolved", reason: "Answered via follow-up question", }, ], addedEdges: [newEdge], removedEdgeIds: [], resolvedUnknownNodeIds: ["n4"], affectedNodeIds: [], }); expect(applyResult.success).toBe(true); expect(applyResult.nodes.length).toBe(graph.nodes.length + 1); expect(applyResult.edges.length).toBe(graph.edges.length + 1); expect(applyResult.resolvedNodeIds).toContain("n4"); // Verify post-apply integrity const postValidation = validateGraphReferences(applyResult); expect(postValidation.valid).toBe(true); }); it("reject and retry: invalid update should be caught", () => { const graph = makeTestGraph(); const invalidUpdate = { addedNodes: [], updatedNodes: [ { nodeId: "ghost-node", newStatus: "known", reason: "test" }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }; // Validation should catch it expect(validateGraphUpdate(graph, invalidUpdate).valid).toBe(false); // Apply should also catch it expect(applyGraphUpdate(graph, invalidUpdate).success).toBe(false); }); it("preserve unchanged nodes during update", () => { const graph = makeTestGraph(); const originalNode1 = JSON.parse(JSON.stringify(graph.nodes[0])); applyGraphUpdate(graph, { addedNodes: [], updatedNodes: [ { nodeId: "n4", previousStatus: "unknown", newStatus: "resolved", reason: "Test preserve", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: ["n4"], affectedNodeIds: [], }); // Re-read the graph and check n1 wasn't modified expect(graph.nodes[0].id).toBe("n1"); expect(graph.nodes[0].status).toBe("unknown"); // unchanged }); }); // ── Semantic-to-mutation contract (57J.39) ────────────── describe("semantic-to-mutation contract", () => { const baseUpdate = { addedNodes: [], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], }; // Test 1 — semantic-only no-op (v0.23: requires structuralActionRequired to be present) it("REJECTS when userSupportedMeaning populated and zero structural mutation without structuralActionRequired", () => { const graph = makeTestGraph(); const update = { ...baseUpdate, answerMeaning: { userSupportedMeaning: "The user states that cost reduction is a primary driver.", possibleInference: null, }, }; const result = validateGraphUpdate(graph, update); expect(result.valid).toBe(false); // Missing-field rejection fires first (transition rule) expect( result.errors.some((e) => e.includes("structuralActionRequired must be present")), ).toBe(true); // Semantic-only guard does NOT fire when field was absent (to avoid duplicate) expect( result.errors.some((e) => e.includes("userSupportedMeaning") && e.includes("mutation")), ).toBe(false); }); // Test 2 — ordinary no-op (answerMeaning null) it("REJECTS with existing 'no meaningful change' when answerMeaning is null and zero structural mutation", () => { const graph = makeTestGraph(); const update = { ...baseUpdate, answerMeaning: null, }; const result = validateGraphUpdate(graph, update); expect(result.valid).toBe(false); expect(result.errors.some((e) => e.includes("no meaningful"))).toBe(true); }); // Test 3 — possibleInference only it("does NOT trigger the new userSupportedMeaning-specific error when only possibleInference is populated", () => { const graph = makeTestGraph(); const update = { ...baseUpdate, answerMeaning: { userSupportedMeaning: null, possibleInference: "Cost reduction could be achieved through staff consolidation.", }, }; const result = validateGraphUpdate(graph, update); expect(result.valid).toBe(false); // Must NOT have the semantic-specific error (userSupportedMeaning is not populated) expect( result.errors.some((e) => e.includes("userSupportedMeaning") && e.includes("mutation")), ).toBe(false); // Generic no-op still applies expect(result.errors.some((e) => e.includes("no meaningful"))).toBe(true); }); // Test 4 — update existing structure (valid structural progress) it("ACCEPTS past new guard when userSupportedMeaning populated AND valid existing-node status change", () => { const graph = makeTestGraph(); const update = { ...baseUpdate, structuralActionRequired: true, answerMeaning: { userSupportedMeaning: "The user confirms risk is a hard constraint.", possibleInference: null, }, updatedNodes: [ { nodeId: "n4", previousStatus: "unknown", newStatus: "known", reason: "Confirmed by user answer", }, ], }; const result = validateGraphUpdate(graph, update); expect(result.valid).toBe(true); expect(result.errors.length).toBe(0); }); // Test 5 — resolve existing unknown (counts as structural progress) it("counts as structural progress when userSupportedMeaning populated AND valid resolution of existing unknown", () => { const graph = makeTestGraph(); const update = { ...baseUpdate, structuralActionRequired: true, answerMeaning: { userSupportedMeaning: "The user provides criteria for acceptable opportunity.", possibleInference: null, }, updatedNodes: [ { nodeId: "n4", previousStatus: "unknown", newStatus: "resolved", reason: "Threshold defined by user", }, ], resolvedUnknownNodeIds: ["n4"], }; const result = validateGraphUpdate(graph, update); expect(result.valid).toBe(true); expect(result.errors.length).toBe(0); }); // Test 6 — add new structure (counts as structural progress) it("counts as structural progress when userSupportedMeaning populated AND valid added unknown", () => { const graph = makeTestGraph(); const update = { ...baseUpdate, structuralActionRequired: true, answerMeaning: { userSupportedMeaning: "The user needs evidence for both savings realism and retention impact.", possibleInference: null, }, addedNodes: [makeNode({ id: "n-new-unknown", label: "New unknown" })], }; const result = validateGraphUpdate(graph, update); expect(result.valid).toBe(true); expect(result.errors.length).toBe(0); }); // Test 7 — duplicate avoidance preserved it("still rejects duplicate node IDs even with populated userSupportedMeaning", () => { const graph = makeTestGraph(); const existingNode = graph.nodes[0]; const update = { ...baseUpdate, structuralActionRequired: true, answerMeaning: { userSupportedMeaning: "There is a new constraint the user identified.", possibleInference: null, }, addedNodes: [existingNode], // Duplicate ID — should still be rejected }; const result = validateGraphUpdate(graph, update); expect(result.valid).toBe(false); expect(result.errors.some((e) => e.includes("duplicate ID"))).toBe(true); }); // Test 8 — userSupportedMeaning with meaningful value change (no status change) it("counts as structural progress when userSupportedMeaning populated AND valid existing-node value change", () => { const graph = makeTestGraph(); const update = { ...baseUpdate, structuralActionRequired: true, answerMeaning: { userSupportedMeaning: "The user clarified the constraint is absolute.", possibleInference: null, }, updatedNodes: [ { nodeId: "n4", previousStatus: null, newStatus: null, previousValue: null, newValue: "absolute_constraint", reason: "Clarified by user answer", }, ], }; const result = validateGraphUpdate(graph, update); expect(result.valid).toBe(true); expect(result.errors.length).toBe(0); }); });