/** * Experiment 47 — Can Existing Graph Relationships Reveal a Shared Investigation Thread? * * Passive diagnostic experiment. Tests whether existing graph relationship fields * (dependsOn, affects, childIds, parentId, edges) can distinguish: * A — several unknowns contributing to one coherent investigation; * B — several unknowns belonging to unrelated lines of enquiry; * C — several unknowns with no usable relationship data. * * No production code changes. No existing fixture modification. */ import { describe, it, expect } from "vitest"; import assessInvestigationState from "@/lib/assessment/investigation-state-assessor.js"; import selectBehaviour from "@/lib/behaviour-selection/behaviour-selector.js"; /* ═══════════════════════════════════════════════════════════ * Test-only diagnostic helper * ═══════════════════════════════════════════════════════════ */ /** * Inspect whether active unknowns share a common graph anchor. * Uses only existing fields: dependsOn, affects, childIds, parentId, edges. * Does not inspect node labels or descriptions (no text matching). * * Deterministic rules: * shared_anchor — all active unknowns reference exactly one common node; * separate_anchors — unknowns have relationship data but no common anchor; * insufficient_data — relationships are absent or incomplete. */ function inspectSharedUnknownAnchor({ graph }) { const nodes = (graph && Array.isArray(graph.nodes)) ? [...graph.nodes] : []; const edges = (graph && Array.isArray(graph.edges)) ? [...graph.edges] : []; // Collect active unknowns (unknown kind, not resolved) 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" }; } // Gather all referenced parent IDs from dependsOn, affects, parentId on the active nodes const referrerMap = new Map(); // nodeId -> Set of referenced node IDs 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); } // Also gather referenced parent IDs from edges where the active unknown is the target 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); } } // Merge edge anchors into each referrer's set for (const key of referrerMap.keys()) { edgeAnchors.forEach((a) => referrerMap.get(key).add(a)); } // Find common anchor: intersection of all referrer sets 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))); } // Filter out non-existent node IDs from common 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] }; } // No common intersection — collect all referenced existing nodes as anchors 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" }; } // No relationship data at all return { result: "insufficient_data", anchorIds: [], reason: "no relationship fields populated on any active unknown" }; return { result: "insufficient_data", anchorIds: [], reason: "no relationship fields populated on any active unknown" }; } /* ═══════════════════════════════════════════════════════════ * Node and edge builder helpers * ═══════════════════════════════════════════════════════════ */ function mkNode(id, label, opts = {}) { const kind = opts.kind || "unknown"; const status = opts.status || (kind === "unknown" ? "unknown" : "known"); const confidence = opts.confidence || (kind === "unknown" ? "low" : "high"); return { id, label, description: label, kind, status, confidence, evidenceIds: [], dependsOn: opts.dependsOn ?? [], affects: opts.affects ?? [], parentId: opts.parentId ?? null, childIds: opts.childIds ?? [] }; } function mkEdge(id, fromNodeId, toNodeId, relationship) { return { id, fromNodeId, toNodeId, relationship: relationship || "supports" }; } /* ═══════════════════════════════════════════════════════════ * Fixture A — Shared anchor: all four unknowns depend on one decision node * Node count: 6 (1 obs + 1 context + 4 unknowns) * ═══════════════════════════════════════════════════════════ */ function buildFixtureA() { const nodes = [ mkNode("obs-1", "The current service handles approximately 200 requests per day across existing regions", { kind: "observation", status: "known", confidence: "medium" }), mkNode("ctx-1", "North West region market evaluation in progress", { kind: "state", status: "provisional", confidence: "medium" }), mkNode("u-a1", "Whether customer demand exists in the North West region", { dependsOn: ["ctx-1"], affects: ["ctx-1"] }), mkNode("u-a2", "What price point the North West market would accept", { dependsOn: ["ctx-1"], affects: ["ctx-1"] }), mkNode("u-a3", "Whether delivery infrastructure can support the North West region", { dependsOn: ["ctx-1"], affects: ["ctx-1"] }), mkNode("u-a4", "Whether regulatory requirements allow operation in the North West", { dependsOn: ["ctx-1"], affects: ["ctx-1"] }) ]; const edges = [ mkEdge("e-1", "obs-1", "ctx-1", "supports"), mkEdge("e-2", "u-a1", "ctx-1", "supports"), mkEdge("e-3", "u-a2", "ctx-1", "supports"), mkEdge("e-4", "u-a3", "ctx-1", "supports"), mkEdge("e-5", "u-a4", "ctx-1", "supports") ]; return { situationGraph: { centralStatement: "Where should I focus my investigation?", currentSummary: "Multiple unknowns related to one decision.", nodes, edges, activeUnknownNodeId: "u-a1", resolvedNodeIds: [] }, selectedQuestion: null, noQuestionReason: "No clear decision target yet.", diagnostics: { promptVersion: "v0.4", modelName: "mock-ollama", responseDurationMs: 0, validationStatus: "valid", nodeCount: nodes.length, edgeCount: edges.length, reasoningPattern: null } }; } /* ═══════════════════════════════════════════════════════════ * Fixture B — Separate anchors: each unknown depends on a different parent * Node count: 6 (1 obs + 1 context + 4 unknowns) * ═══════════════════════════════════════════════════════════ */ function buildFixtureB() { const nodes = [ mkNode("obs-1", "The business has been operating for five years without significant growth", { kind: "observation", status: "known", confidence: "medium" }), mkNode("ctx-1", "Strategic planning review cycle active", { kind: "state", status: "provisional", confidence: "medium" }), mkNode("u-b1", "Whether customer demand exists in new geographic markets", { dependsOn: ["ctx-1"] }), mkNode("u-b2", "Whether staff conflict is the primary cause of reduced productivity", { parentId: "ctx-1" }), mkNode("u-b3", "Whether relocating would attract a different talent pool", { affects: ["ctx-1"] }), mkNode("u-b4", "Whether current pricing aligns with competitor offerings") ]; const edges = [mkEdge("e-1", "obs-1", "ctx-1", "supports")]; return { situationGraph: { centralStatement: "Where should I focus my investigation?", currentSummary: "Multiple unknowns related to one decision.", nodes, edges, activeUnknownNodeId: "u-b1", resolvedNodeIds: [] }, selectedQuestion: null, noQuestionReason: "No clear decision target yet.", diagnostics: { promptVersion: "v0.4", modelName: "mock-ollama", responseDurationMs: 0, validationStatus: "valid", nodeCount: nodes.length, edgeCount: edges.length, reasoningPattern: null } }; } /* ═══════════════════════════════════════════════════════════ * Fixture C — No usable relationships: four unknowns with empty relationship fields * Node count: 6 (1 obs + 1 context + 4 unknowns) * ═══════════════════════════════════════════════════════════ */ function buildFixtureC() { const nodes = [ mkNode("obs-1", "A manufacturing company reports complaints increased by 35%", { kind: "observation", status: "known", confidence: "medium" }), mkNode("ctx-1", "Quality review process initiated", { kind: "state", status: "provisional", confidence: "medium" }), mkNode("u-c1", "Whether the complaint increase is sector-wide or product-specific"), mkNode("u-c2", "Whether production volume explains the complaint trend"), mkNode("u-c3", "Whether quality control procedures are consistently applied"), mkNode("u-c4", "Whether reporting standards have changed during the period") ]; return { situationGraph: { centralStatement: "Where should I focus my investigation?", currentSummary: "Multiple unknowns related to one decision.", nodes, edges: [], activeUnknownNodeId: "u-c1", resolvedNodeIds: [] }, selectedQuestion: null, noQuestionReason: "No clear decision target yet.", diagnostics: { promptVersion: "v0.4", modelName: "mock-ollama", responseDurationMs: 0, validationStatus: "valid", nodeCount: nodes.length, edgeCount: 0, reasoningPattern: null } }; } /* ═══════════════════════════════════════════════════════════ * Clarify eligibility helper (matches production rule) * ═══════════════════════════════════════════════════════════ */ function isClarifyEligible(assessment) { if (assessment.conversationHealth.value === "too_broad") return true; if (assessment.phase.value === "orienting" && assessment.phase.evidence?.observationDensity < 3) return true; return false; } /* ═══════════════════════════════════════════════════════════ * Tests — Structural equality across fixtures * ═══════════════════════════════════════════════════════════ */ describe("Experiment 47 — Shared-Anchor Coherence Diagnostic", () => { /* ── Build fixtures once for all tests ── */ let fixtureA, fixtureB, fixtureC; let resultA, resultB, resultC; let selA, selB, selC; let clarEligibleA, clarEligibleB, clarEligibleC; let diagA, diagB, diagC; beforeAll(() => { fixtureA = buildFixtureA(); fixtureB = buildFixtureB(); fixtureC = buildFixtureC(); resultA = assessInvestigationState(fixtureA); resultB = assessInvestigationState(fixtureB); resultC = assessInvestigationState(fixtureC); selA = selectBehaviour(resultA); selB = selectBehaviour(resultB); selC = selectBehaviour(resultC); clarEligibleA = isClarifyEligible(resultA); clarEligibleB = isClarifyEligible(resultB); clarEligibleC = isClarifyEligible(resultC); diagA = inspectSharedUnknownAnchor({ graph: fixtureA.situationGraph }); diagB = inspectSharedUnknownAnchor({ graph: fixtureB.situationGraph }); diagC = inspectSharedUnknownAnchor({ graph: fixtureC.situationGraph }); }); /* ═══ Structural equality (identical counts) ═══ */ describe("Structural equality across fixtures", () => { const verifyCounts = (g, name) => { const nodes = g.nodes || []; const unknowns = nodes.filter((n) => n.kind === "unknown" && n.status !== "resolved"); const resolved = g.resolvedNodeIds ? g.resolvedNodeIds.filter((id) => nodes.find((n) => n.id === id)) : []; const observations = nodes.filter((n) => (n.kind === "observation" && n.status === "known")); expect(unknowns.length).toBe(4).withContext(name + ": active unknown count"); expect(resolved.length).toBe(0).withContext(name + ": resolved count"); expect(observations.length).toBe(1).withContext(name + ": observation count"); }; it("Fixture A: 4 active unknowns, 0 resolved, 1 observation", () => verifyCounts(fixtureA.situationGraph, "A")); it("Fixture B: 4 active unknowns, 0 resolved, 1 observation", () => verifyCounts(fixtureB.situationGraph, "B")); it("Fixture C: 4 active unknowns, 0 resolved, 1 observation", () => verifyCounts(fixtureC.situationGraph, "C")); it("all fixtures have no selected question", () => { expect(fixtureA.selectedQuestion).toBeNull(); expect(fixtureB.selectedQuestion).toBeNull(); expect(fixtureC.selectedQuestion).toBeNull(); }); it("all fixtures use identical confidence and status values per node type", () => { for (const [name, fg] of [["A", fixtureA], ["B", fixtureB], ["C", fixtureC]]) { const nodes = fg.situationGraph.nodes; expect(nodes.filter((n) => n.kind === "unknown").every((n) => n.status === "unknown" && n.confidence === "low")).toBe(true); expect(nodes.filter((n) => n.kind === "observation")[0].status).toBe("known"); expect(nodes.filter((n) => n.kind === "observation")[0].confidence).toBe("medium"); } }); it("only relationship structure differs between fixtures", () => { const countDeps = (fg) => fg.situationGraph.nodes.reduce((sum, n) => sum + (Array.isArray(n.dependsOn) ? n.dependsOn.length : 0), 0); expect(countDeps(fixtureA)).toBeGreaterThan(0).withContext("Fixture A has dependency relationships"); expect(countDeps(fixtureB)).toBeGreaterThan(0).withContext("Fixture B has dependency relationships"); expect(countDeps(fixtureC)).toBe(0).withContext("Fixture C has no dependency relationships"); expect(fixtureA.situationGraph.edges.length).toBe(5); expect(fixtureB.situationGraph.edges.length).toBe(1); expect(fixtureC.situationGraph.edges.length).toBe(0); }); }); /* ═══ Diagnostic helper tests ═══ */ describe("Shared-anchor diagnostic", () => { it("Fixture A returns shared_anchor", () => { expect(diagA.result).toBe("shared_anchor"); expect(diagA.anchorIds.length).toBe(1); expect(diagA.anchorIds[0]).toBe("ctx-1"); }); it("Fixture B returns separate_anchors", () => { expect(diagB.result).toBe("separate_anchors"); expect(diagB.anchorIds.length).toBeGreaterThan(0); }); it("Fixture C returns insufficient_data", () => { expect(diagC.result).toBe("insufficient_data"); expect(diagC.anchorIds.length).toBe(0); }); }); /* ═══ Assessor results (existing production logic) ═══ */ describe("Assessor outputs (production unchanged)", () => { it("all fixtures produce the same health result", () => { expect(resultA.conversationHealth.value).toBe(resultB.conversationHealth.value); expect(resultB.conversationHealth.value).toBe(resultC.conversationHealth.value); expect(resultA.conversationHealth.value).toBe("too_broad"); }); it("all fixtures produce the same phase result", () => { expect(resultA.phase.value).toBe(resultB.phase.value); expect(resultB.phase.value).toBe(resultC.phase.value); expect(resultA.phase.value).toBe("cannot_determine"); }); it("all fixtures produce the same progress result", () => { expect(resultA.progress.value).toBe(resultB.progress.value); expect(resultB.progress.value).toBe(resultC.progress.value); expect(resultA.progress.value).toBe("cannot_determine"); }); it("health evidence shows activeUnknownCount = 4 for all", () => { expect(resultA.conversationHealth.evidence.activeUnknownCount).toBe(4); expect(resultB.conversationHealth.evidence.activeUnknownCount).toBe(4); expect(resultC.conversationHealth.evidence.activeUnknownCount).toBe(4); }); }); /* ═══ Clarify eligibility ═══ */ describe("Clarify eligibility", () => { it("all fixtures make Clarify eligible via too_broad Rule A", () => { expect(clarEligibleA).toBe(true); expect(clarEligibleB).toBe(true); expect(clarEligibleC).toBe(true); }); it("selector behaviour matches Clarify eligibility", () => { expect(selA.behaviour).toBe("clarify"); expect(selB.behaviour).toBe("clarify"); expect(selC.behaviour).toBe("clarify"); }); }); /* ═══ Key discrimination test ═══ */ describe("Does the diagnostic distinguish coherent from scattered?", () => { it("diagnostic distinguishes Fixture A (shared) from Fixture B (separate)", () => { expect(diagA.result).not.toBe(diagB.result); expect(diagA.result).toBe("shared_anchor"); expect(diagB.result).toBe("separate_anchors"); }); it("diagnostic distinguishes Fixture C (insufficient) from Fixture A", () => { expect(diagC.result).not.toBe(diagA.result); }); it("assessor does NOT distinguish any pair — all identical regardless of graph relationships", () => { const copy = (r) => { const c = JSON.parse(JSON.stringify(r)); delete c.assessedAt; return c; }; expect(JSON.stringify(copy(resultA))).toBe(JSON.stringify(copy(resultB))); expect(JSON.stringify(copy(resultB))).toBe(JSON.stringify(copy(resultC))); }); it("the diagnostic result does not affect the assessor output", () => { const copy = (r) => { const c = JSON.parse(JSON.stringify(r)); delete c.assessedAt; return c; }; expect(JSON.stringify(copy(resultA))).not.toContain("shared_anchor"); expect(JSON.stringify(copy(resultB))).not.toContain("separate_anchors"); }); }); /* ═══ Determinism and immutability ═══ */ describe("Determinism and immutability", () => { it("diagnostic helper is deterministic across multiple calls", () => { const d1A = inspectSharedUnknownAnchor({ graph: fixtureA.situationGraph }); const d2A = inspectSharedUnknownAnchor({ graph: fixtureA.situationGraph }); expect(d1A.result).toBe(d2A.result); expect(JSON.stringify(d1A.anchorIds)).toBe(JSON.stringify(d2A.anchorIds)); const d1B = inspectSharedUnknownAnchor({ graph: fixtureB.situationGraph }); const d2B = inspectSharedUnknownAnchor({ graph: fixtureB.situationGraph }); expect(d1B.result).toBe(d2B.result); const d1C = inspectSharedUnknownAnchor({ graph: fixtureC.situationGraph }); const d2C = inspectSharedUnknownAnchor({ graph: fixtureC.situationGraph }); expect(d1C.result).toBe(d2C.result); }); it("inputs are not mutated by the assessor", () => { const aSnap = JSON.stringify(fixtureA); const bSnap = JSON.stringify(fixtureB); const cSnap = JSON.stringify(fixtureC); assessInvestigationState(fixtureA); assessInvestigationState(fixtureB); assessInvestigationState(fixtureC); expect(JSON.stringify(fixtureA)).toBe(aSnap); expect(JSON.stringify(fixtureB)).toBe(bSnap); expect(JSON.stringify(fixtureC)).toBe(cSnap); }); it("inputs are not mutated by the diagnostic helper", () => { const g = JSON.parse(JSON.stringify(fixtureA.situationGraph)); inspectSharedUnknownAnchor({ graph: g }); expect(JSON.stringify(g)).toEqual(JSON.stringify(fixtureA.situationGraph)); }); it("fixtures and helper remain test-only", () => { expect(true).toBe(true); }); }); /* ═══ Existing scenario inspection ═══ */ describe("Existing scenarios from Experiments 39-46", () => { // Real shapes from existing tests — not rewritten, just inspected const existingScenarios = [ { name: "comparison-turn-2 (Exp 39/41/45 real data path)", graph: { nodes: [ { id: "obs-1", kind: "observation", status: "known", confidence: "high", dependsOn: [], affects: [], parentId: null, childIds: [] }, { id: "obs-2", kind: "observation", status: "known", confidence: "high", dependsOn: [], affects: [], parentId: null, childIds: [] }, { id: "obs-3", kind: "observation", status: "known", confidence: "high", dependsOn: [], affects: [], parentId: null, childIds: [] }, { id: "obs-4", kind: "observation", status: "known", confidence: "high", dependsOn: [], affects: [], parentId: null, childIds: [] }, { id: "obs-5", kind: "observation", status: "known", confidence: "medium", dependsOn: [], affects: [], parentId: null, childIds: [] }, { id: "state-1", kind: "state", status: "provisional", confidence: "medium", dependsOn: [], affects: [], parentId: null, childIds: [] }, { id: "u-1", kind: "unknown", status: "resolved", confidence: "high", dependsOn: ["obs-1"], affects: [], parentId: null, childIds: [] }, { id: "u-2", kind: "unknown", status: "resolved", confidence: "medium", dependsOn: ["obs-3"], affects: [], parentId: null, childIds: [] }, { id: "u-3", kind: "unknown", status: "unknown", confidence: "low", dependsOn: [], affects: [], parentId: null, childIds: [] } ], edges: [], resolvedNodeIds: ["u-1", "u-2"], } }, { name: "long-turn-3 (Exp 45 real data path)", graph: { nodes: [ { id: "obs-1", kind: "observation", status: "known", confidence: "high", dependsOn: [], affects: [], parentId: null, childIds: [] }, { id: "obs-2", kind: "observation", status: "known", confidence: "medium", dependsOn: [], affects: [], parentId: null, childIds: [] }, { id: "obs-3", kind: "observation", status: "known", confidence: "high", dependsOn: [], affects: [], parentId: null, childIds: [] }, { id: "obs-4", kind: "observation", status: "known", confidence: "medium", dependsOn: [], affects: [], parentId: null, childIds: [] }, { id: "state-1", kind: "state", status: "provisional", confidence: "medium", dependsOn: [], affects: [], parentId: null, childIds: [] }, { id: "u-1", kind: "unknown", status: "resolved", confidence: "medium", dependsOn: [], affects: [], parentId: null, childIds: [] }, { id: "u-2", kind: "unknown", status: "resolved", confidence: "high", dependsOn: [], affects: [], parentId: null, childIds: [] }, { id: "u-3", kind: "unknown", status: "resolved", confidence: "medium", dependsOn: [], affects: [], parentId: null, childIds: [] }, { id: "u-4", kind: "unknown", status: "unknown", confidence: "low", dependsOn: [], affects: [], parentId: null, childIds: [] } ], edges: [], resolvedNodeIds: ["u-1", "u-2", "u-3"], } }, { name: "live-ollama-state (Exp 46 test shape)", graph: { nodes: [ { id: "obs-1", kind: "observation", status: "known", confidence: "high", dependsOn: [], affects: [] }, { id: "obs-2", kind: "observation", status: "known", confidence: "medium", dependsOn: [], affects: [] }, { id: "u-1", kind: "unknown", status: "unknown", confidence: "low", dependsOn: ["obs-1", "obs-2"], affects: [] } ], edges: [ { id: "e-1", fromNodeId: "obs-1", toNodeId: "u-1", relationship: "supports" }, { id: "e-2", fromNodeId: "obs-2", toNodeId: "u-1", relationship: "supports" } ], resolvedNodeIds: [], } } ]; let existingResults; beforeAll(() => { existingResults = existingScenarios.map((s) => inspectSharedUnknownAnchor({ graph: s.graph })); }); it("all existing scenarios return insufficient_data", () => { for (const r of existingResults) { expect(r.result).toBe("insufficient_data"); } }); it("diagnostic results for each existing scenario", () => { for (const [i, s] of existingScenarios.entries()) { console.log("\n=== Existing Scenario: " + s.name + " ==="); console.log(" result: " + existingResults[i].result); console.log(" anchorIds: " + JSON.stringify(existingResults[i].anchorIds)); console.log(" reason: " + existingResults[i].reason); } }); it("none contain a usable shared-anchor signal", () => { const hasShared = existingResults.some((r) => r.result === "shared_anchor"); expect(hasShared).toBe(false); }); }); });