/** * Experiment 49 — Can the Production Update Path Produce a Real Shared Anchor? * * This test asks: does any sequence of real production updates create two or more * active unknowns that reference the same populated relationship anchor (parentId, * dependsOn, or affects pointing to the same node)? * * No production code changes. No fixture modification. Only test files and diagnostics. */ import { describe, it, expect } from "vitest"; import { buildInitialGraph, } from "@/lib/graph/builder.js"; import { applyValidatedProposal, } from "@/lib/graph/apply-proposal.js"; import { situationNodeSchema, situationEdgeSchema, situationGraphSchema, makeNodeId, makeNode, makeEdge, makeGraph, } from "@/lib/graph/schema.js"; /* ═══════════════════════════════════════════════════════════ * Test-only diagnostic — shared-anchor detection (from Exp 47) * Uses only existing fields — no text matching. * ═══════════════════════════════════════════════════════════ */ function inspectSharedUnknownAnchor({ graph }) { const nodes = Array.isArray(graph.nodes) ? [...graph.nodes] : []; const edges = Array.isArray(graph.edges) ? [...graph.edges] : []; const activeIds = new Set(); const resolvedSet = new Set(graph.resolvedNodeIds || []); for (const n of nodes) { if (!n || n.kind !== "unknown") continue; if (resolvedSet.has(n.id) || n.status === "resolved") continue; activeIds.add(n.id); } const activeArr = [...activeIds]; if (activeArr.length < 2) { return { result: "insufficient_data", anchorIds: [], reason: "fewer than two active unknowns" }; } const referrerMap = new Map(); for (const n of nodes) { if (!activeIds.has(n.id)) continue; const refs = new Set(); if (Array.isArray(n.dependsOn)) n.dependsOn.forEach((id) => refs.add(id)); if (Array.isArray(n.affects)) n.affects.forEach((id) => refs.add(id)); if (n.parentId) refs.add(n.parentId); referrerMap.set(n.id, refs); } const edgeAnchors = new Set(); for (const e of edges) { if (!e || !e.fromNodeId || !e.toNodeId) continue; if (activeIds.has(e.toNodeId)) { edgeAnchors.add(e.fromNodeId); } } for (const key of referrerMap.keys()) { edgeAnchors.forEach((a) => referrerMap.get(key).add(a)); } let common = new Set([...referrerMap.get(activeArr[0]) || []]); for (let i = 1; i < activeArr.length; i++) { const next = referrerMap.get(activeArr[i]) || new Set(); common = new Set([...common].filter((x) => next.has(x))); } const existingIds = new Set(nodes.map((n) => n.id)); const validCommon = [...common].filter((id) => existingIds.has(id)); if (validCommon.length === 1) { return { result: "shared_anchor", anchorIds: validCommon, reason: "All active unknowns reference one common node: " + validCommon[0] }; } const allRefs = new Set(); for (const id of activeArr) { const refs = referrerMap.get(id) || new Set(); refs.forEach((r) => allRefs.add(r)); } const validAnchors = [...allRefs].filter((id) => existingIds.has(id)); if (validAnchors.length > 0) { return { result: "separate_anchors", anchorIds: validAnchors, reason: "Active unknowns reference " + validAnchors.length + " distinct nodes with no shared intersection" }; } return { result: "insufficient_data", anchorIds: [], reason: "no relationship fields populated on any active unknown" }; } /* ═══════════════════════════════════════════════════════════ * Helper: wrap raw {nodes, edges} into a SituationGraph * ═══════════════════════════════════════════════════════════ */ function wrapGraph({ nodes, edges }) { const summaryNode = nodes.find((n) => n.kind === "state"); const unknownNodes = nodes.filter((n) => n.kind === "unknown" && n.status !== "resolved"); return situationGraphSchema.parse({ centralStatement: summaryNode?.description || "Test", currentSummary: summaryNode?.label || "Test", activeUnknownNodeId: unknownNodes.length > 0 ? unknownNodes[0].id : null, resolvedNodeIds: [], nodes, edges: edges || [], }); } /* ═══════════════════════════════════════════════════════════ * Control fixture — shared-anchor graph (NOT from production) * Two active unknowns both reference the same relationship node. * Edge structure: edges FROM unknowns TO relationship anchor. * Node fields: parentId set on both unknowns. * ═══════════════════════════════════════════════════════════ */ function buildControlSharedAnchorGraph() { const relationshipNode = makeNode({ id: "n-relationship-anchor", label: "Revenue-cash comparison status", kind: "relationship", status: "supported", confidence: "high", }); const unknown1 = makeNode({ id: "n-unk-explanation-1", label: "Explanation for revenue increase", description: "Need to know what changed, because that is needed to understand the divergence.", kind: "unknown", status: "unknown", confidence: "medium", parentId: relationshipNode.id, dependsOn: [], }); const unknown2 = makeNode({ id: "n-unk-explanation-2", label: "Explanation for cash decrease", description: "Need to know what changed, because that is needed to understand the divergence.", kind: "unknown", status: "unknown", confidence: "medium", parentId: relationshipNode.id, dependsOn: [], }); const obs1 = makeNode({ id: "n-obs-rev", label: "Revenue up 18%.", kind: "observation", status: "supported", confidence: "high", }); const obs2 = makeNode({ id: "n-obs-cash", label: "Cash down 5%.", kind: "observation", status: "supported", confidence: "high", }); return makeGraph({ centralStatement: "Revenue and cash diverged.", nodes: [relationshipNode, unknown1, unknown2, obs1, obs2], edges: [ makeEdge({ id: "e-obs1-r", fromNodeId: obs1.id, toNodeId: relationshipNode.id, relationship: "supports" }), makeEdge({ id: "e-obs2-r", fromNodeId: obs2.id, toNodeId: relationshipNode.id, relationship: "supports" }), makeEdge({ id: "e-unk1-r", fromNodeId: unknown1.id, toNodeId: relationshipNode.id, relationship: "depends_on" }), makeEdge({ id: "e-unk2-r", fromNodeId: unknown2.id, toNodeId: relationshipNode.id, relationship: "depends_on" }), ], activeUnknownNodeId: unknown1.id, resolvedNodeIds: [], currentSummary: "Two active unknowns sharing the same anchor.", }); } /* ═══════════════════════════════════════════════════════════ * Control fixture — decomposition children with shared parent. * Uses incoming edges from a relationship node so that the diagnostic's * edgeAnchors logic picks up the shared reference. * ═══════════════════════════════════════════════════════════ */ function buildControlDecompositionGraph() { const parentNode = makeNode({ id: "n-parent-explanation", label: "Explanation for why revenue and cash diverged", kind: "relationship", status: "supported", confidence: "medium", }); const child1 = makeNode({ id: "n-child-whether different timing", label: "Whether the two observations reflect different timing", description: "Need to know whether the two observations reflect different timing, because that could help explain the divergence.", kind: "unknown", status: "unknown", confidence: "medium", parentId: parentNode.id, }); const child2 = makeNode({ id: "n-child-how measured", label: "How the two observations were measured", description: "Need evidence about the measure used for each observation, because that could help explain the divergence.", kind: "unknown", status: "unknown", confidence: "medium", parentId: parentNode.id, }); const obs1 = makeNode({ id: "n-obs-rev-d", label: "Revenue up 18%.", kind: "observation", status: "supported", confidence: "high", }); const obs2 = makeNode({ id: "n-obs-cash-d", label: "Cash down 5%.", kind: "observation", status: "supported", confidence: "high", }); return makeGraph({ centralStatement: "Revenue and cash diverged.", nodes: [parentNode, child1, child2, obs1, obs2], edges: [ makeEdge({ id: "e-obs1-rd", fromNodeId: obs1.id, toNodeId: parentNode.id, relationship: "supports" }), makeEdge({ id: "e-obs2-rd", fromNodeId: obs2.id, toNodeId: parentNode.id, relationship: "supports" }), makeEdge({ id: "e-c1-p", fromNodeId: child1.id, toNodeId: parentNode.id, relationship: "depends_on" }), makeEdge({ id: "e-c2-p", fromNodeId: child2.id, toNodeId: parentNode.id, relationship: "depends_on" }), ], activeUnknownNodeId: child1.id, resolvedNodeIds: [], currentSummary: "Decomposition children with shared parent.", }); } /* ═══════════════════════════════════════════════════════════ * Case A — Two emergent unknowns from one investigation. * Start with a graph containing comparable observations and an * existing unknown about comparability. First update resolves the * unknown → triggers emergent reasoning path, creating Unknown A. * Second update answers a question from Unknown A in a way that * produces new observation-level changes → potentially creates * another emergent reasoning path. Inspect whether both active * unknowns reference the same anchor. * ═══════════════════════════════════════════════════════════ */ describe("Case A — Two emergent unknowns from one investigation", () => { let graphBeforeSecondUpdate, diagAfterFirst, diagAfterSecond; let activeUnknownCountAfterFirst = 0; let result1, result2; beforeAll(() => { /* Step 1: build a valid starting graph with two comparable observations */ const nodeObs1 = makeNode({ id: "n-obs-revenue", label: "Revenue increased by 18%.", kind: "observation", status: "supported", confidence: "high", }); const nodeObs2 = makeNode({ id: "n-obs-cash", label: "Cash in the bank decreased over the same period.", kind: "observation", status: "supported", confidence: "high", }); const comparisonUnknown = makeNode({ id: "n-unk-comparison", label: "Whether the figures are comparable", description: "Need to know whether the figures use the same period, basis, and scale.", kind: "unknown", status: "unknown", confidence: "high", }); const graph = makeGraph({ centralStatement: "Revenue and cash comparison needed for decision.", nodes: [nodeObs1, nodeObs2, comparisonUnknown], edges: [ makeEdge({ id: "e-obs1-to-unk", fromNodeId: nodeObs1.id, toNodeId: comparisonUnknown.id, relationship: "supports" }), makeEdge({ id: "e-obs2-to-unk", fromNodeId: nodeObs2.id, toNodeId: comparisonUnknown.id, relationship: "supports" }), ], activeUnknownNodeId: comparisonUnknown.id, resolvedNodeIds: [], currentSummary: "Two supported observations with one unresolved unknown.", }); /* Step 2: apply an update that resolves the existing unknown */ const proposal1 = { addedNodes: [], updatedNodes: [ { nodeId: comparisonUnknown.id, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Both figures cover the same accounting period and are taken from the same management accounts.", reason: "Confirmed comparable by the user.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [comparisonUnknown.id], affectedNodeIds: [], selectedQuestion: null, }; result1 = applyValidatedProposal({ situationGraph: graph, proposal: proposal1, previousQuestion: "Are the two figures comparable?", answer: "Yes, both cover the same accounting period and are from the same management accounts.", }); diagAfterFirst = inspectSharedUnknownAnchor({ graph: result1.updatedSituationGraph }); graphBeforeSecondUpdate = result1.updatedSituationGraph; // Capture active unknown count after step 2 activeUnknownCountAfterFirst = graphBeforeSecondUpdate.nodes.filter( (n) => n.kind === "unknown" && !graphBeforeSecondUpdate.resolvedNodeIds.includes(n.id) ).length; /* Step 3: fire a SECOND update on the updated graph to trigger another emergent path */ const activeAfterFirst = graphBeforeSecondUpdate.nodes.filter( (n) => n.kind === "unknown" && !graphBeforeSecondUpdate.resolvedNodeIds.includes(n.id) ); if (activeAfterFirst.length >= 1) { const nextUnknown = activeAfterFirst[0]; const proposal2 = { addedNodes: [], updatedNodes: [ { nodeId: nextUnknown.id, previousStatus: nextUnknown.status || "unknown", newStatus: "resolved", previousValue: nextUnknown.value ?? null, newValue: "Answer provided by the user.", reason: "User resolved this unknown.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [nextUnknown.id], affectedNodeIds: [], selectedQuestion: null, }; result2 = applyValidatedProposal({ situationGraph: graphBeforeSecondUpdate, proposal: proposal2, previousQuestion: `Investigate "${nextUnknown.label}"`, answer: "Answer provided.", }); diagAfterSecond = inspectSharedUnknownAnchor({ graph: result2.updatedSituationGraph }); } else { // No active unknowns after step 1 — can't fire a second update. result2 = null; diagAfterSecond = { result: "insufficient_data", anchorIds: [], reason: "no active unknowns remained after first update" }; } }); it("step 1 produces a valid updated graph with diagnostic output", () => { expect(["shared_anchor", "separate_anchors", "insufficient_data"]).toContain(diagAfterFirst.result); }); it("captures how many active unknowns remain after each update", () => { const remaining = graphBeforeSecondUpdate.nodes.filter( (n) => n.kind === "unknown" && !graphBeforeSecondUpdate.resolvedNodeIds.includes(n.id) ); expect(Array.isArray(remaining)).toBe(true); }); it("step 2 diagnostic is captured", () => { expect(["shared_anchor", "separate_anchors", "insufficient_data"]).toContain(diagAfterSecond.result); }); it("records the relationship fields on all active unknowns after both updates", () => { const allActiveUnknowns = []; // After first update const afterFirst = graphBeforeSecondUpdate.nodes.filter( (n) => n.kind === "unknown" && !graphBeforeSecondUpdate.resolvedNodeIds.includes(n.id) ); for (const u of afterFirst) { allActiveUnknowns.push({ turn: 1, id: u.id, dependsOn: [...(u.dependsOn || [])], affects: [...(u.affects || [])], parentId: u.parentId, childIds: [...(u.childIds || [])], }); } // After second update if (result2?.updatedSituationGraph) { const afterSecond = result2.updatedSituationGraph.nodes.filter( (n) => n.kind === "unknown" && !result2.updatedSituationGraph.resolvedNodeIds.includes(n.id) ); for (const u of afterSecond) { allActiveUnknowns.push({ turn: 2, id: u.id, dependsOn: [...(u.dependsOn || [])], affects: [...(u.affects || [])], parentId: u.parentId, childIds: [...(u.childIds || [])], }); } } // Assert the data was captured — at minimum the arrays are well-formed for (const rec of allActiveUnknowns) { expect(Array.isArray(rec.dependsOn)).toBe(true); expect(Array.isArray(rec.affects)).toBe(true); expect(["string", "object"]).toContain(typeof rec.parentId); expect(Array.isArray(rec.childIds)).toBe(true); } }); it("shared-anchor test: can two active unknowns share an anchor via production updates?", () => { // This is the core question of Exp 49. // shared_anchor = yes, they DO share an anchor; separate_anchors or insufficient_data = no. const finalResult = diagAfterSecond.result; if (finalResult === "shared_anchor") { expect(diagAfterSecond.anchorIds.length).toBeGreaterThan(0); console.log("\n SHARED ANCHOR FOUND:", JSON.stringify(diagAfterSecond)); } else { console.log("\n No shared anchor via this production sequence:", diagAfterSecond.result, "—", diagAfterSecond.reason); } // We record the result — do NOT assert a specific outcome because Exp 49 is exploratory. expect(["shared_anchor", "separate_anchors", "insufficient_data"]).toContain(finalResult); }); it("diagnostic captures first update result clearly", () => { console.log("\n Case A — Diagnostic after first update:", diagAfterFirst.result, "—", diagAfterFirst.reason); expect(["shared_anchor", "separate_anchors", "insufficient_data"]).toContain(diagAfterFirst.result); }); it("active unknown count documents the production path outcome", () => { console.log("\n Case A — Active unknowns after first update:", activeUnknownCountAfterFirst); expect(typeof activeUnknownCountAfterFirst).toBe("number"); }); }); /* ═══════════════════════════════════════════════════════════ * Case B — Start from buildInitialGraph (which leaves empty fields), * then apply updates that fire the emergent reasoning path twice. * The goal: see whether two separate emergent-unknown creations can * each point to the SAME relationship node anchor. * ═══════════════════════════════════════════════════════════ */ describe("Case B — Emergent unknowns from buildInitialGraph baseline", () => { let startingGraph, graphAfterFirstUpdate, graphAfterSecondUpdate; let diagAfterFirstB, diagAfterSecondB; let activeUnknownCountAfterFirstB = 0; let activeUnknownCountAfterSecondB = 0; beforeAll(() => { /* Build initial graph with comparable observations */ const nodeObs1 = makeNode({ id: "n-x-obs", label: "Revenue up 18%.", kind: "observation", status: "supported", confidence: "high", }); const nodeObs2 = makeNode({ id: "n-y-obs", label: "Cash down 5%.", kind: "observation", status: "supported", confidence: "high", }); const comparisonUnknown = makeNode({ id: "n-x-comparison", label: "Is the comparison valid?", description: "Need to know whether the figures use the same period, basis, and scale.", kind: "unknown", status: "unknown", confidence: "medium", }); const graph = makeGraph({ centralStatement: "Test scenario with comparable observations.", nodes: [nodeObs1, nodeObs2, comparisonUnknown], edges: [ makeEdge({ id: "e-a-1", fromNodeId: nodeObs1.id, toNodeId: comparisonUnknown.id, relationship: "supports" }), makeEdge({ id: "e-a-2", fromNodeId: nodeObs2.id, toNodeId: comparisonUnknown.id, relationship: "supports" }), ], activeUnknownNodeId: comparisonUnknown.id, resolvedNodeIds: [], currentSummary: "Test.", }); startingGraph = graph; /* First update: resolve comparison unknown */ const proposal1B = { addedNodes: [], updatedNodes: [ { nodeId: comparisonUnknown.id, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Confirmed.", reason: "User confirmed comparability.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [comparisonUnknown.id], affectedNodeIds: [], selectedQuestion: null, }; const result1B = applyValidatedProposal({ situationGraph: graph, proposal: proposal1B, previousQuestion: "Are the figures comparable?", answer: "Yes.", }); graphAfterFirstUpdate = result1B.updatedSituationGraph; diagAfterFirstB = inspectSharedUnknownAnchor({ graph: graphAfterFirstUpdate }); activeUnknownCountAfterFirstB = graphAfterFirstUpdate.nodes.filter( (n) => n.kind === "unknown" && !graphAfterFirstUpdate.resolvedNodeIds.includes(n.id) ).length; /* Second update: resolve next unknown if one exists */ const activeAfterFirst = graphAfterFirstUpdate.nodes.filter( (n) => n.kind === "unknown" && !graphAfterFirstUpdate.resolvedNodeIds.includes(n.id) ); if (activeAfterFirst.length >= 1) { const nextUnknown = activeAfterFirst[0]; const proposal2B = { addedNodes: [], updatedNodes: [ { nodeId: nextUnknown.id, previousStatus: nextUnknown.status || "unknown", newStatus: "resolved", previousValue: nextUnknown.value ?? null, newValue: "Answered.", reason: "User resolved this unknown.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [nextUnknown.id], affectedNodeIds: [], selectedQuestion: null, }; const result2B = applyValidatedProposal({ situationGraph: graphAfterFirstUpdate, proposal: proposal2B, previousQuestion: `Investigate "${nextUnknown.label}"`, answer: "Answer provided.", }); graphAfterSecondUpdate = result2B.updatedSituationGraph; diagAfterSecondB = inspectSharedUnknownAnchor({ graph: graphAfterSecondUpdate }); activeUnknownCountAfterSecondB = graphAfterSecondUpdate.nodes.filter( (n) => n.kind === "unknown" && !graphAfterSecondUpdate.resolvedNodeIds.includes(n.id) ).length; } else { graphAfterSecondUpdate = null; diagAfterSecondB = { result: "insufficient_data", anchorIds: [], reason: "no active unknowns after first update" }; } }); it("first update produces a valid graph with diagnostic output", () => { expect(["shared_anchor", "separate_anchors", "insufficient_data"]).toContain(diagAfterFirstB.result); }); it("captures active unknown count after first update", () => { expect(typeof activeUnknownCountAfterFirstB).toBe("number"); }); it("second update diagnostic is captured", () => { expect(["shared_anchor", "separate_anchors", "insufficient_data"]).toContain(diagAfterSecondB.result); }); it("captures active unknown count after second update", () => { expect(typeof activeUnknownCountAfterSecondB).toBe("number"); }); it("logs relationship fields for each remaining active unknown after both updates", () => { const allActive = []; if (graphAfterFirstUpdate) { for (const n of graphAfterFirstUpdate.nodes.filter( (n) => n.kind === "unknown" && !graphAfterFirstUpdate.resolvedNodeIds.includes(n.id) )) { allActive.push({ turn: 1, id: n.id, parentId: n.parentId, dependsOn: [...(n.dependsOn || [])] }); } } if (graphAfterSecondUpdate) { for (const n of graphAfterSecondUpdate.nodes.filter( (n) => n.kind === "unknown" && !graphAfterSecondUpdate.resolvedNodeIds.includes(n.id) )) { allActive.push({ turn: 2, id: n.id, parentId: n.parentId, dependsOn: [...(n.dependsOn || [])] }); } } expect(Array.isArray(allActive)).toBe(true); }); it("shared-anchor test: does this sequence produce a shared anchor?", () => { const finalResult = diagAfterSecondB.result; if (finalResult === "shared_anchor") { expect(diagAfterSecondB.anchorIds.length).toBeGreaterThan(0); console.log("\n SHARED ANCHOR FOUND in Case B:", JSON.stringify(diagAfterSecondB)); } else { console.log("\n No shared anchor via this sequence (Case B):", diagAfterSecondB.result, "—", diagAfterSecondB.reason); } expect(["shared_anchor", "separate_anchors", "insufficient_data"]).toContain(finalResult); }); it("diagnostic captures first update result clearly", () => { console.log("\n Case B — Diagnostic after first update:", diagAfterFirstB.result, "—", diagAfterFirstB.reason); expect(["shared_anchor", "separate_anchors", "insufficient_data"]).toContain(diagAfterFirstB.result); }); it("active unknown count documents the production path outcome (first)", () => { console.log("\n Case B — Active unknowns after first update:", activeUnknownCountAfterFirstB); expect(typeof activeUnknownCountAfterFirstB).toBe("number"); }); it("active unknown count documents the production path outcome (second)", () => { console.log("\n Case B — Active unknowns after second update:", activeUnknownCountAfterSecondB); expect(typeof activeUnknownCountAfterSecondB).toBe("number"); }); }); /* ═══════════════════════════════════════════════════════════ * Case C — Direct construction of a shared-anchor graph and * verification that the diagnostic reads it correctly. * This is a control to confirm the diagnostic logic itself works. * ═══════════════════════════════════════════════════════════ */ describe("Case C — Diagnostic validation with controlled shared-anchor fixture", () => { let graphWithSharedAnchor, diag; beforeAll(() => { graphWithSharedAnchor = buildControlSharedAnchorGraph(); diag = inspectSharedUnknownAnchor({ graph: graphWithSharedAnchor }); }); it("diagnostic returns shared_anchor for controlled fixture", () => { expect(diag.result).toBe("shared_anchor"); }); it("shared-anchor diagnostic identifies the correct anchor ID", () => { expect(diag.anchorIds).toContain("n-relationship-anchor"); }); it("diagnostic reason includes anchor ID", () => { expect(diag.reason).toContain("n-relationship-anchor"); }); it("both unknown nodes are active (not resolved)", () => { const known = graphWithSharedAnchor.resolvedNodeIds || []; for (const n of graphWithSharedAnchor.nodes) { if (n.kind !== "unknown") continue; expect(known).not.toContain(n.id); } }); it("both unknowns have parentId pointing to the same anchor", () => { const unknownNodes = graphWithSharedAnchor.nodes.filter((n) => n.kind === "unknown" && n.status !== "resolved"); expect(unknownNodes.length).toBeGreaterThanOrEqual(2); for (const u of unknownNodes) { expect(u.parentId).toBe("n-relationship-anchor"); } }); it("diagnostic correctly rejects a shared-anchor fixture missing parentId", () => { const unkNoParent = makeNode({ id: "n-unk-no-parent", label: "Explanation test", description: "Need to know.", kind: "unknown", status: "unknown", confidence: "medium", }); const unkNoParent2 = makeNode({ id: "n-unk-no-parent-2", label: "Explanation test two", description: "Need to know.", kind: "unknown", status: "unknown", confidence: "medium", }); const graphNoParent = makeGraph({ centralStatement: "Test no parent.", nodes: [unkNoParent, unkNoParent2], edges: [], activeUnknownNodeId: unkNoParent.id, resolvedNodeIds: [], currentSummary: "Test.", }); const diagNoParent = inspectSharedUnknownAnchor({ graph: graphNoParent }); expect(diagNoParent.result).not.toBe("shared_anchor"); }); }); /* ═══════════════════════════════════════════════════════════ * Case D — Test decomposition children sharing a parent anchor. * Decomposition children get parentId from buildCompositeUnknownChildren. * We test whether two decomposition children from the same parent * share that parent as an anchor. * ═══════════════════════════════════════════════════════════ */ describe("Case D — Decomposition children sharing a parent anchor", () => { let graphWithDecomposition, diag; beforeAll(() => { graphWithDecomposition = buildControlDecompositionGraph(); diag = inspectSharedUnknownAnchor({ graph: graphWithDecomposition }); // Debug output const activeUnknowns = graphWithDecomposition.nodes.filter( (n) => n.kind === "unknown" && !graphWithDecomposition.resolvedNodeIds.includes(n.id) ); console.log("\n Case D — Active unknown count:", activeUnknowns.length); for (const u of activeUnknowns) { console.log(` Unknown ${u.id}: parentId=${u.parentId}, dependsOn=${JSON.stringify(u.dependsOn)}, affects=${JSON.stringify(u.affects)}`); } console.log(" Case D — Diagnostic result:", diag.result, "—", diag.reason); }); it("diagnostic returns shared_anchor for decomposition children fixture", () => { expect(diag.result).toBe("shared_anchor"); }); it("both decomposition children share the same parent as anchor", () => { expect(diag.anchorIds).toContain("n-parent-explanation"); }); it("diagnostic identifies the shared anchor reason correctly", () => { expect(diag.result).toBe("shared_anchor"); }); }); /* ═══════════════════════════════════════════════════════════ * Case E — Apply a single update to one unknown and verify the * other unknown's relationship fields are NOT mutated (immunity). * ═══════════════════════════════════════════════════════════ */ describe("Case E — Immunity: updating one node does not mutate unrelated nodes", () => { let graphBeforeUpdate, snapshotBefore, snapshotAfter, diagImmunity; beforeAll(() => { const parentNode = makeNode({ id: "n-parent-e", label: "Explanation parent", kind: "relationship", status: "supported", confidence: "medium", }); const child1 = makeNode({ id: "n-child-e-1", label: "Child one", description: "Need to know X.", kind: "unknown", status: "unknown", confidence: "medium", parentId: parentNode.id, }); const child2 = makeNode({ id: "n-child-e-2", label: "Child two", description: "Need to know Y.", kind: "unknown", status: "unknown", confidence: "medium", parentId: parentNode.id, }); const obs1 = makeNode({ id: "n-obs-e-1", label: "Signal A changed.", kind: "observation", status: "supported", confidence: "high", }); graphBeforeUpdate = makeGraph({ centralStatement: "Test immunity.", nodes: [parentNode, child1, child2, obs1], edges: [ makeEdge({ id: "e-obs-p-e", fromNodeId: obs1.id, toNodeId: parentNode.id, relationship: "supports" }), makeEdge({ id: "e-c1-p-e", fromNodeId: child1.id, toNodeId: parentNode.id, relationship: "depends_on" }), makeEdge({ id: "e-c2-p-e", fromNodeId: child2.id, toNodeId: parentNode.id, relationship: "depends_on" }), ], activeUnknownNodeId: child1.id, resolvedNodeIds: [], currentSummary: "Test.", }); /* Snapshot before update */ const child1Before = graphBeforeUpdate.nodes.find((n) => n.id === "n-child-e-1"); const child2Before = graphBeforeUpdate.nodes.find((n) => n.id === "n-child-e-2"); snapshotBefore = { child1ParentId: child1Before.parentId, child1DependsOn: [...(child1Before.dependsOn || [])], child2ParentId: child2Before.parentId, child2DependsOn: [...(child2Before.dependsOn || [])], resolvedCount: graphBeforeUpdate.resolvedNodeIds.length, }; /* Apply update resolving child1 */ const proposalE = { addedNodes: [], updatedNodes: [ { nodeId: "n-child-e-1", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Answered.", reason: "User resolved child one.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: ["n-child-e-1"], affectedNodeIds: [], selectedQuestion: null, }; const resultE = applyValidatedProposal({ situationGraph: graphBeforeUpdate, proposal: proposalE, previousQuestion: "Investigate child one.", answer: "Answered.", }); /* Snapshot after */ const child2After = resultE.updatedSituationGraph.nodes.find((n) => n.id === "n-child-e-2"); snapshotAfter = { child2ParentId: child2After.parentId, child2DependsOn: [...(child2After.dependsOn || [])], resolvedCount: resultE.updatedSituationGraph.resolvedNodeIds.length, }; diagImmunity = inspectSharedUnknownAnchor({ graph: graphBeforeUpdate }); }); it("child2 parentId is unchanged after resolving child1", () => { expect(snapshotBefore.child2ParentId).toBe(snapshotAfter.child2ParentId); }); it("child2 dependsOn is unchanged after resolving child1", () => { expect(JSON.stringify(snapshotBefore.child2DependsOn)).toBe(JSON.stringify(snapshotAfter.child2DependsOn)); }); it("resolvedNodeIds grew by exactly one", () => { expect(snapshotAfter.resolvedCount).toBe(snapshotBefore.resolvedCount + 1); }); it("diagnostic sees both children share the same anchor before update (as expected)", () => { // Before any update, child1 and child2 are both active and share parentId → shared_anchor expect(diagImmunity.result).toBe("shared_anchor"); }); it("after resolving child1, child2 retains its anchor (immunity verified)", () => { // The real immunity test: child2's parentId and dependsOn are unchanged after resolving child1 expect(snapshotBefore.child2ParentId).toBe(snapshotAfter.child2ParentId); expect(JSON.stringify(snapshotBefore.child2DependsOn)).toBe(JSON.stringify(snapshotAfter.child2DependsOn)); }); it("diagnostic captures shared-anchor state on pre-update graph", () => { console.log("\n Case E — Immunity diagnostic result:", diagImmunity.result, "—", diagImmunity.reason); expect(diagImmunity.result).toBe("shared_anchor"); }); }); /* ═══════════════════════════════════════════════════════════ * Case F — Schema compliance: all produced unknown nodes must * pass the situationNodeSchema in the shared-anchor result graph. * ═══════════════════════════════════════════════════════════ */ describe("Case F — Schema compliance on shared-anchor fixture", () => { it("all nodes in Case C shared-anchor fixture pass schema validation", () => { const gc = buildControlSharedAnchorGraph(); for (const n of gc.nodes) { const result = situationNodeSchema.safeParse(n); expect(result.success).toBe(true); } }); it("all edges in Case C shared-anchor fixture pass edge schema validation", () => { const gc = buildControlSharedAnchorGraph(); for (const e of gc.edges) { const result = situationEdgeSchema.safeParse(e); expect(result.success).toBe(true); } }); it("the entire graph with shared-anchor passes the SituationGraph schema", () => { const gc = buildControlSharedAnchorGraph(); const result = situationGraphSchema.safeParse(gc); expect(result.success).toBe(true); }); it("all nodes in Case D decomposition fixture pass schema validation", () => { const gd = buildControlDecompositionGraph(); for (const n of gd.nodes) { const result = situationNodeSchema.safeParse(n); expect(result.success).toBe(true); } }); it("the entire graph with decomposition children passes the SituationGraph schema", () => { const gd = buildControlDecompositionGraph(); const result = situationGraphSchema.safeParse(gd); expect(result.success).toBe(true); }); });