/** * Experiment 48 — Do Real Graph Updates Populate Usable Unknown Relationships? * * Passive implementation audit. Exercises real production graph-construction functions * and inspects whether relationship fields on unknown nodes are populated in a way * that the Experiment-47 shared-anchor diagnostic can interpret. * * No production code changes. No existing fixture modification. */ 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 (copied from Exp 47; inspectSharedUnknownAnchor) * 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 {nodes, edges} from buildInitialGraph 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 || [], }); } /* ═══════════════════════════════════════════════════════════ * Reconstruction fixture — multiple unknowns from one central situation * ═══════════════════════════════════════════════════════════ */ function makeMultiUnknownReconstruction() { return { summary: "Company X reports revenue growth but increasing complaints", actors: [ { id: "actor-1", description: "Customer Base", confidence: "high" }, { id: "actor-2", description: "Product Engineering Team", confidence: "high" }, ], systemsOrObjects: [ { id: "sys-1", description: "Production Line A", confidence: "high" }, ], expectedStates: [], observedStates: [ { id: "obs-1", description: "Revenue up 15% year-over-year", confidence: "high" }, { id: "obs-2", description: "Customer complaints up 40% year-over-year", confidence: "medium" }, ], differences: [ { id: "diff-1", description: "Complaint count grew faster than revenue", confidence: "medium" }, ], unexplainedTransitions: [], knownTransitions: [], contradictions: [ { id: "con-1", description: "Revenue growth vs complaint growth inconsistency", confidence: "high" }, ], importantUnknowns: [ { id: "unk-1", description: "Whether competitor pricing drove the decline", confidence: "medium" }, { id: "unk-2", description: "Whether product quality issues caused customer churn", confidence: "medium" }, { id: "unk-3", description: "Whether supply chain disruptions reduced availability", confidence: "low" }, ], plausibleInterpretations: [], }; } /* ═══════════════════════════════════════════════════════════ * Case A — Multiple unknowns from one investigation * ═══════════════════════════════════════════════════════════ */ describe("Case A — Multiple unknowns from one investigation (buildInitialGraph)", () => { let result, situationGraph; beforeAll(() => { const raw = buildInitialGraph({ reconstruction: makeMultiUnknownReconstruction(), evidence: [] }); situationGraph = wrapGraph(raw); }); it("production path creates at least two unknown nodes", () => { const unknowns = situationGraph.nodes.filter((n) => n.kind === "unknown" && n.status !== "resolved"); expect(unknowns.length).toBeGreaterThanOrEqual(2); }); it("all unknown nodes have edges to the summary node", () => { const unknownIds = new Set(situationGraph.nodes.filter((n) => n.kind === "unknown" && n.status !== "resolved").map((n) => n.id)); for (const uid of unknownIds) { const edgeExists = situationGraph.edges.some( (e) => e.fromNodeId === uid && e.relationship === "depends_on", ); expect(edgeExists).toBe(true); } }); it("node-level relationship fields are empty from buildInitialGraph path", () => { const unknowns = situationGraph.nodes.filter((n) => n.kind === "unknown" && n.status !== "resolved"); for (const u of unknowns) { expect(Array.isArray(u.dependsOn)).toBe(true); expect(u.dependsOn.length).toBe(0); expect(Array.isArray(u.affects)).toBe(true); expect(u.affects.length).toBe(0); expect(u.parentId).toBeNull(); expect(Array.isArray(u.childIds)).toBe(true); } }); it("diagnostic returns insufficient_data (no common anchor derivable from real output)", () => { const diag = inspectSharedUnknownAnchor({ graph: situationGraph }); // This captures whether the production path produces usable shared-anchor signal expect(["shared_anchor", "separate_anchors", "insufficient_data"]).toContain(diag.result); }); it("unknown nodes are valid per schema (no parse drift)", () => { for (const n of situationGraph.nodes) { if (n.kind === "unknown") { const parsed = situationNodeSchema.safeParse(n); expect(parsed.success).toBe(true); } } }); }); /* ═══════════════════════════════════════════════════════════ * Case B — Unknowns created across separate production updates * ═══════════════════════════════════════════════════════════ */ describe("Case B — Unknowns via production update path (applyValidatedProposal)", () => { let initialGraph, firstResult; beforeAll(() => { // Start with a graph that has comparable observations and one existing unknown const node1 = makeNode({ id: "n-obs-first", label: "Revenue increased by 18%.", kind: "observation", status: "supported", confidence: "high", }); const node2 = makeNode({ id: "n-obs-second", label: "Cash in the bank decreased over the same period.", kind: "observation", status: "supported", confidence: "high", }); const existingUnknown = makeNode({ id: "n-existing-unk", 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", }); initialGraph = makeGraph({ centralStatement: "Revenue and cash comparison needed for decision.", nodes: [node1, node2, existingUnknown], edges: [ makeEdge({ id: "e-1", fromNodeId: node1.id, toNodeId: existingUnknown.id, relationship: "supports", }), makeEdge({ id: "e-2", fromNodeId: node2.id, toNodeId: existingUnknown.id, relationship: "supports", }), ], activeUnknownNodeId: existingUnknown.id, resolvedNodeIds: [], currentSummary: "Initial graph with comparable observations and one unknown.", }); // Apply an update that resolves the existing unknown — exercises applyGraphUpdate + emergent reasoning path const proposal = { addedNodes: [], updatedNodes: [ { nodeId: existingUnknown.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.", }, ], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [existingUnknown.id], affectedNodeIds: [], selectedQuestion: null, }; firstResult = applyValidatedProposal({ situationGraph: initialGraph, proposal, }); }); it("production update returns success", () => { expect(firstResult.success).toBe(true); }); it("result contains updatedSituationGraph with graph data", () => { expect(firstResult.updatedSituationGraph.nodes.length).toBeGreaterThan(0); }); it("records which unknowns exist after the update and their relationship fields", () => { const nodes = firstResult.updatedSituationGraph.nodes; const activeUnknowns = nodes.filter( (n) => n.kind === "unknown" && !firstResult.updatedSituationGraph.resolvedNodeIds.includes(n.id), ); for (const u of activeUnknowns) { console.log(`\n Unknown: ${u.id}`); console.log(` dependsOn: ${JSON.stringify(u.dependsOn)}`); console.log(` affects: ${JSON.stringify(u.affects)}`); console.log(` parentId: ${u.parentId}`); console.log(` childIds: ${JSON.stringify(u.childIds)}`); const edges = firstResult.updatedSituationGraph.edges.filter( (e) => e.fromNodeId === u.id || e.toNodeId === u.id, ); console.log(` edgeCount: ${edges.length}`); console.log(` linkedNodeIds: ${JSON.stringify(edges.map((e) => e.fromNodeId === u.id ? e.toNodeId : e.fromNodeId))}`); } expect(activeUnknowns.length).toBeGreaterThanOrEqual(0); }); it("diagnostic classification applied to update result", () => { const diag = inspectSharedUnknownAnchor({ graph: firstResult.updatedSituationGraph }); console.log(`\n Diagnostic result for Case B: ${diag.result} — ${diag.reason}`); expect(["shared_anchor", "separate_anchors", "insufficient_data"]).toContain(diag.result); }); it("initial graph is not mutated by applyValidatedProposal", () => { const snap = JSON.stringify(initialGraph); applyValidatedProposal({ situationGraph: initialGraph, proposal: { addedNodes: [], updatedNodes: [], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], affectedNodeIds: [], selectedQuestion: null } }); expect(JSON.stringify(initialGraph)).toBe(snap); }); }); /* ═══════════════════════════════════════════════════════════ * Case C — Child or decomposed unknowns * Not supported by the inspected production path without additional setup * (decomposition requires a compound question on an existing unknown node). * ═══════════════════════════════════════════════════════════ */ describe("Case C — Child or decomposed unknowns", () => { it("reports unsupported for the inspected production path", () => { // The decomposition path (runDeterministicDecomposition) requires an existing unknown // with a selected compound question to trigger. Our Case B update resolves the only // unknown, so no decomposition is exercised. console.log("\n Case C: Not supported by the inspected production path — decomposition requires a compound question on an active unknown node."); expect(true).toBe(true); }); }); /* ═══════════════════════════════════════════════════════════ * Existing-fixture audit — inspect real scenarios from Exp 47 for comparison * ═══════════════════════════════════════════════════════════ */ describe("Existing-scenario diagnostic baseline", () => { const scenarios = [ { name: "comparison-turn-2 (Exp 39/41/45 real data path)", graph: { nodes: [ { id: "obs-1", kind: "observation", status: "known", confidence: "high" }, { id: "u-1", kind: "unknown", status: "resolved", confidence: "high" }, { id: "u-2", kind: "unknown", status: "unknown", confidence: "low" }, ], edges: [], resolvedNodeIds: ["u-1"], }, }, { name: "long-turn-3 (Exp 45 real data path)", graph: { nodes: [ { id: "obs-1", kind: "observation", status: "known", confidence: "high" }, { id: "u-1", kind: "unknown", status: "resolved", confidence: "medium" }, { id: "u-2", kind: "unknown", status: "resolved", confidence: "high" }, { id: "u-3", kind: "unknown", status: "unknown", confidence: "low" }, ], edges: [], resolvedNodeIds: ["u-1", "u-2"], }, }, ]; let results; beforeAll(() => { results = scenarios.map((s) => inspectSharedUnknownAnchor({ graph: s.graph })); }); it("all existing-scenario graphs return insufficient_data or fewer_than_two_active_unknowns", () => { for (const r of results) { expect(r.result).toBe("insufficient_data"); } }); it("shows diagnostic results for each scenario", () => { for (const [i, s] of scenarios.entries()) { console.log(`\n Scenario: ${s.name}`); console.log(` result: ${results[i].result}`); console.log(` reason: ${results[i].reason}`); } }); }); /* ═══════════════════════════════════════════════════════════ * Relationship-field audit — verify schema compliance of all produced unknown nodes * ═══════════════════════════════════════════════════════════ */ describe("Relationship field completeness on all production-created unknowns", () => { let caseAUnknowns, caseBNodes; beforeAll(() => { const raw = buildInitialGraph({ reconstruction: makeMultiUnknownReconstruction(), evidence: [] }); const sg = wrapGraph(raw); caseAUnknowns = sg.nodes.filter((n) => n.kind === "unknown" && n.status !== "resolved"); const node1 = makeNode({ id: "n-x-obs", label: "Revenue up 18%.", kind: "observation", status: "supported", confidence: "high" }); const node2 = makeNode({ id: "n-y-obs", label: "Cash down 5%.", kind: "observation", status: "supported", confidence: "high" }); const u1 = makeNode({ id: "n-x-unk", label: "Is the comparison valid?", kind: "unknown", status: "unknown", confidence: "medium" }); const g = makeGraph({ centralStatement: "Test", currentSummary: "Test", activeUnknownNodeId: u1.id, resolvedNodeIds: [], nodes: [node1, node2, u1], edges: [makeEdge({ id: "e-a", fromNodeId: node1.id, toNodeId: u1.id, relationship: "supports" })], }); const p = { addedNodes: [], updatedNodes: [{ nodeId: u1.id, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Yes.", reason: "test" }], addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [u1.id], affectedNodeIds: [], selectedQuestion: null }; caseBNodes = applyValidatedProposal({ situationGraph: g, proposal: p }).updatedSituationGraph.nodes; }); it("all Case A unknown nodes have required relationship fields (array types)", () => { for (const u of caseAUnknowns) { expect(Array.isArray(u.dependsOn)).toBe(true); expect(Array.isArray(u.affects)).toBe(true); expect(Array.isArray(u.childIds)).toBe(true); } }); it("all Case A unknown nodes have required relationship fields (null parentId)", () => { for (const u of caseAUnknowns) { expect(typeof u.parentId).toBe("object"); // null is type object in JS } }); it("edge references exist on all production-created unknowns", () => { // Rebuild Case A graph locally for edge inspection const raw = buildInitialGraph({ reconstruction: makeMultiUnknownReconstruction(), evidence: [] }); for (const u of caseAUnknowns) { const edgeCount = raw.edges.filter((e) => e.fromNodeId === u.id || e.toNodeId === u.id).length; // buildInitialGraph creates exactly one depends_on edge per unknown (to summary) expect(edgeCount).toBeGreaterThanOrEqual(1); } }); });