Creates tests/graph/shared-anchor-production-path.test.js (36 tests, all pass). Experiment 49 asks whether any sequence of real production updates via applyValidatedProposal creates two or more active unknowns sharing the same populated relationship anchor. Two sequential-update scenarios (Cases A & B) consistently returned separate_anchors or insufficient_data — no shared anchor observed in tested flows. Control cases C–F confirm: diagnostic correctly distinguishes shared vs separated patterns on controlled fixtures; all produced nodes/edges pass schema validation; resolving one node does not mutate another (immunity); decomposition children share parent anchor correctly. Combined regression suite: 78 tests across Exp 47 (26), Exp 48 (16), Exp 49 (36) — all passing, no production code modified.
956 lines
37 KiB
JavaScript
956 lines
37 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|