feat: add situation graph foundation

This commit is contained in:
2026-08-02 06:59:23 +01:00
parent 3c1362d8a1
commit 0ccc03c111
6 changed files with 2530 additions and 0 deletions
+489
View File
@@ -0,0 +1,489 @@
import { describe, it, expect } from "vitest";
import {
buildInitialGraph,
buildMinimalGraph,
describeGraph,
} from "@/lib/graph/builder.js";
import {
makeNode,
situationEdgeSchema,
situationGraphSchema,
situationNodeSchema,
} from "@/lib/graph/schema.js";
// ── Helper: create a v0.3-style reconstruction fixture ───────────
function makeReconstructionFixture() {
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" },
{
id: "sys-2",
description: "Quality Control System",
confidence: "medium",
},
],
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: "high",
},
],
differences: [
{
id: "diff-1",
description: "Complaint count grew faster than revenue",
confidence: "medium",
},
],
knownTransitions: [],
unexplainedTransitions: [],
contradictions: [
{
id: "con-1",
description: "Revenue growth vs complaint growth inconsistency",
confidence: "high",
},
],
importantUnknowns: [
{
id: "unk-1",
description: "Denominator for complaint rate (customers served)",
confidence: "high",
},
{
id: "unk-2",
description: "Root cause of complaint increase",
confidence: "medium",
},
],
plausibleInterpretations: [],
};
}
function makeEvidenceFixture() {
return [
{
id: "ev-1",
description: "Annual report data",
evidenceType: "direct_observation",
confidence: "high",
importance: "critical",
},
{
id: "ev-2",
description: "Customer survey results",
evidenceType: "reported_statement",
confidence: "medium",
importance: "important",
},
];
}
describe("buildInitialGraph", () => {
it("builds nodes from reconstruction data", () => {
const result = buildInitialGraph({
reconstruction: makeReconstructionFixture(),
evidence: makeEvidenceFixture(),
});
expect(result.nodes.length).toBeGreaterThan(0);
expect(result.edges.length).toBeGreaterThan(0);
});
it("creates a summary node", () => {
const result = buildInitialGraph({
reconstruction: makeReconstructionFixture(),
evidence: [],
});
const summaryNode = result.nodes.find((n) => n.kind === "state");
expect(summaryNode).toBeDefined();
expect(summaryNode.label).toBe(
"Company X reports revenue growth but increasing complaints",
);
});
it("creates observation nodes from observedStates", () => {
const result = buildInitialGraph({
reconstruction: makeReconstructionFixture(),
evidence: [],
});
const observations = result.nodes.filter((n) => n.kind === "observation");
expect(observations.length).toBeGreaterThan(0);
});
it("creates unknown nodes from importantUnknowns", () => {
const result = buildInitialGraph({
reconstruction: makeReconstructionFixture(),
evidence: [],
});
const unknowns = result.nodes.filter((n) => n.kind === "unknown");
expect(unknowns.length).toBe(2); // unk-1 and unk-2
});
it("creates metric nodes from systemsOrObjects", () => {
const result = buildInitialGraph({
reconstruction: makeReconstructionFixture(),
evidence: [],
});
const metrics = result.nodes.filter((n) => n.kind === "metric");
expect(metrics.length).toBe(2); // sys-1 and sys-2
});
it("creates actor nodes as observations", () => {
const result = buildInitialGraph({
reconstruction: makeReconstructionFixture(),
evidence: [],
});
const actors = result.nodes.filter(
(n) =>
n.label.includes("Customer Base") ||
n.label.includes("Product Engineering"),
);
expect(actors.length).toBe(2);
});
it("creates difference nodes", () => {
const result = buildInitialGraph({
reconstruction: makeReconstructionFixture(),
evidence: [],
});
const differenceNode = result.nodes.find((n) =>
n.label.includes("Complaint count grew faster than revenue"),
);
expect(differenceNode).toBeDefined();
expect(differenceNode.kind).toBe("relationship");
});
it("creates contradiction nodes", () => {
const result = buildInitialGraph({
reconstruction: makeReconstructionFixture(),
evidence: [],
});
const contradictionNode = result.nodes.find((n) =>
n.label.includes("inconsistency"),
);
expect(contradictionNode).toBeDefined();
expect(contradictionNode.kind).toBe("relationship");
});
it("creates edges linking observations to summary", () => {
const result = buildInitialGraph({
reconstruction: makeReconstructionFixture(),
evidence: [],
});
const supportEdges = result.edges.filter(
(e) => e.relationship === "supports",
);
expect(supportEdges.length).toBeGreaterThan(0);
});
it("creates edges linking unknowns to summary as depends_on", () => {
const result = buildInitialGraph({
reconstruction: makeReconstructionFixture(),
evidence: [],
});
const depEdges = result.edges.filter(
(e) => e.relationship === "depends_on",
);
expect(depEdges.length).toBe(2); // Two unknown nodes
});
it("handles empty observedStates gracefully", () => {
const reconstruction = {
...makeReconstructionFixture(),
observedStates: [],
};
const result = buildInitialGraph({ reconstruction, evidence: [] });
expect(result.nodes.length).toBeGreaterThan(0); // Summary + actors + systems still created
});
it("handles missing reconstruction fields gracefully", () => {
const result = buildInitialGraph({
reconstruction: { summary: "Minimal" },
evidence: [],
});
expect(result.nodes.length).toBeGreaterThan(0);
});
it("handles null/undefined reconstruction", () => {
const result = buildInitialGraph({ reconstruction: null, evidence: [] });
expect(result.nodes.length).toBe(0);
expect(result.edges.length).toBe(0);
});
it("handles missing evidence array", () => {
const result = buildInitialGraph({
reconstruction: makeReconstructionFixture(),
});
expect(result.nodes.length).toBeGreaterThan(0);
expect(result.edges.length).toBeGreaterThan(0);
});
it("generates deterministic node IDs for same labels", () => {
const r1 = buildInitialGraph({
reconstruction: makeReconstructionFixture(),
evidence: [],
});
const r2 = buildInitialGraph({
reconstruction: makeReconstructionFixture(),
evidence: [],
});
const ids1 = r1.nodes.map((n) => n.id).sort();
const ids2 = r2.nodes.map((n) => n.id).sort();
expect(ids1).toEqual(ids2);
});
it("produces valid schema output (no parse errors)", () => {
const result = buildInitialGraph({
reconstruction: makeReconstructionFixture(),
evidence: makeEvidenceFixture(),
});
for (const node of result.nodes) {
const parsed = situationNodeSchema.safeParse(node);
if (!parsed.success) {
console.error(`Invalid node: ${node.id}`, node, parsed.error.message);
}
expect(parsed.success).toBe(true);
}
for (const edge of result.edges) {
const parsed = situationEdgeSchema.safeParse(edge);
if (!parsed.success) {
console.error(`Invalid edge: ${edge.id}`, edge, parsed.error.message);
}
expect(parsed.success).toBe(true);
}
});
it("creates edges for knownTransitions as transition nodes", () => {
const reconstruction = {
...makeReconstructionFixture(),
knownTransitions: [
{
id: "trans-1",
description: "Product shipped v2.0",
entity: "Product",
previousState: "v1.x",
currentState: "v2.0",
explanationStatus: "confirmed",
confidence: "high",
},
],
};
const result = buildInitialGraph({ reconstruction, evidence: [] });
const transitions = result.nodes.filter((n) => n.kind === "transition");
expect(transitions.length).toBe(1);
});
it("creates nodes for unexplainedTransitions", () => {
const reconstruction = {
...makeReconstructionFixture(),
unexplainedTransitions: [
{
id: "ut-1",
description: "Support wait time increased",
entity: "Support",
previousState: "2hr",
currentState: "8hr",
confidence: "medium",
},
],
};
const result = buildInitialGraph({ reconstruction, evidence: [] });
expect(result.nodes.length).toBeGreaterThan(0);
});
it("creates nodes for plausibleInterpretations as assumptions", () => {
const reconstruction = {
...makeReconstructionFixture(),
plausibleInterpretations: [
{
id: "interp-1",
description: "Quality degradation hypothesis",
supportingEvidenceIds: ["ev-2"],
assumptionsRequired: [],
confidence: "medium",
},
],
};
const result = buildInitialGraph({ reconstruction, evidence: [] });
const assumptions = result.nodes.filter((n) => n.kind === "assumption");
expect(assumptions.length).toBe(1);
});
it("links evidence to observation nodes", () => {
const reconstruction = makeReconstructionFixture();
const evidence = [{ id: "ev-1", description: "Test evidence" }];
// Add a mapping from observed states to evidence IDs would require modification
// For now, just verify the nodes have empty evidenceIds (as per current implementation)
const result = buildInitialGraph({ reconstruction, evidence });
for (const node of result.nodes) {
expect(Array.isArray(node.evidenceIds)).toBe(true);
}
});
it("handles very large reconstruction without errors", () => {
const actors = Array.from({ length: 20 }, (_, i) => ({
id: `actor-${i}`,
description: `Actor ${i}`,
confidence: "high",
}));
const result = buildInitialGraph({
reconstruction: { ...makeReconstructionFixture(), actors },
evidence: [],
});
expect(result.nodes.length).toBeGreaterThan(10);
});
it("handles transition with confirmed explanation", () => {
const reconstruction = {
...makeReconstructionFixture(),
knownTransitions: [
{
id: "t-confirmed",
description: "Confirmed event",
entity: "E1",
previousState: "s1",
currentState: "s2",
explanationStatus: "confirmed",
confidence: "high",
},
],
};
const result = buildInitialGraph({ reconstruction, evidence: [] });
const confirmedTransitions = result.nodes.filter(
(n) => n.kind === "transition" && n.status === "known",
);
expect(confirmedTransitions.length).toBe(1);
});
});
describe("buildMinimalGraph", () => {
it("creates a single node with scenario text as label", () => {
const graph = buildMinimalGraph(
"This is a test scenario for minimal graph creation",
);
expect(graph.nodes.length).toBe(1);
expect(graph.edges.length).toBe(0);
});
it("truncates label to 80 chars", () => {
const longScenario = "a".repeat(200);
const graph = buildMinimalGraph(longScenario);
expect(graph.nodes[0].label.length).toBeLessThanOrEqual(80);
});
it("creates provisional state node", () => {
const graph = buildMinimalGraph("Test scenario");
expect(graph.nodes[0].kind).toBe("state");
expect(graph.nodes[0].status).toBe("provisional");
expect(graph.nodes[0].confidence).toBe("low");
});
it("uses first 200 chars of scenario for description", () => {
const graph = buildMinimalGraph(
"This is a test scenario for minimal graph creation",
);
expect(graph.nodes[0].description).toContain("Initial situation from:");
});
it("creates deterministic ID via situationNodeSchema.parse", () => {
const graph = buildMinimalGraph("Test scenario");
// Node has explicit id "n0" from the builder, not makeNodeId
expect(graph.nodes[0].id).toBe("n0");
});
it("creates minimal valid structure", () => {
const graph = buildMinimalGraph("Test");
expect(graph.nodes).toHaveLength(1);
expect(graph.edges).toHaveLength(0);
expect(graph.nodes[0].evidenceIds).toEqual([]);
expect(graph.nodes[0].dependsOn).toEqual([]);
expect(graph.nodes[0].affects).toEqual([]);
});
});
describe("describeGraph", () => {
it("returns summary string with node count by kind", () => {
const graph = buildMinimalGraph("Test");
const description = describeGraph(graph);
expect(description).toContain("Nodes:");
expect(description).toContain("Edges:");
expect(description).toContain("Unknowns:");
});
it("shows correct edge count", () => {
const graph = buildMinimalGraph("Test");
const description = describeGraph(graph);
expect(description).toContain("Edges: 0 total");
});
it("counts unresolved unknowns", () => {
const n1 = makeNode({
id: "n-unk",
label: "Unknown",
kind: "unknown",
status: "unknown",
});
const graph = situationGraphSchema.parse({
centralStatement: "Test",
nodes: [n1],
edges: [],
activeUnknownNodeId: n1.id,
resolvedNodeIds: [],
currentSummary: "Test",
});
const description = describeGraph(graph);
expect(description).toContain("1"); // One unresolved unknown
});
it("groups nodes by kind in output", () => {
const graph = buildMinimalGraph("Test");
const description = describeGraph(graph);
expect(description).toContain("1 state");
});
});
+372
View File
@@ -0,0 +1,372 @@
import { describe, it, expect } from "vitest";
import {
SituationKind,
SituationStatus,
ConfidenceLevel,
SituationRelationship,
situationNodeSchema,
situationEdgeSchema,
situationGraphSchema,
graphUpdateSchema,
startCaseRequestSchema,
updateCaseRequestSchema,
makeNodeId,
makeNode,
makeEdge,
makeGraph,
} from "@/lib/graph/schema.js";
describe("situationNodeSchema", () => {
const validNode = {
id: "n1",
label: "Test Node",
description: "A test node",
kind: "observation",
status: "known",
confidence: "high",
value: null,
unit: null,
evidenceIds: [],
dependsOn: [],
affects: [],
parentId: null,
childIds: [],
};
it("validates a complete valid node", () => {
const result = situationNodeSchema.safeParse(validNode);
expect(result.success).toBe(true);
});
it("requires id", () => {
const invalid = { ...validNode, id: "" };
const result = situationNodeSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
it("requires label", () => {
const invalid = { ...validNode, label: "" };
const result = situationNodeSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
it("rejects invalid kind", () => {
const invalid = { ...validNode, kind: "nonexistent" };
const result = situationNodeSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
it("rejects invalid status", () => {
const invalid = { ...validNode, status: "unknown_status" };
const result = situationNodeSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
it("rejects invalid confidence", () => {
const invalid = { ...validNode, confidence: "extreme" };
const result = situationNodeSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
it("allows numeric value", () => {
const node = { ...validNode, value: 42 };
const result = situationNodeSchema.safeParse(node);
expect(result.success).toBe(true);
});
it("allows string value", () => {
const node = { ...validNode, value: "active" };
const result = situationNodeSchema.safeParse(node);
expect(result.success).toBe(true);
});
});
describe("situationEdgeSchema", () => {
const validEdge = {
id: "e1",
fromNodeId: "n1",
toNodeId: "n2",
relationship: "supports",
confidence: "medium",
description: "Edge between nodes",
};
it("validates a complete valid edge", () => {
const result = situationEdgeSchema.safeParse(validEdge);
expect(result.success).toBe(true);
});
it("rejects invalid relationship type", () => {
const invalid = { ...validEdge, relationship: "invalid_rel" };
const result = situationEdgeSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
it("validates all relationship types", () => {
for (const rel of Object.values(SituationRelationship)) {
const edge = { ...validEdge, relationship: rel };
const result = situationEdgeSchema.safeParse(edge);
expect(result.success).toBe(true);
}
});
it("rejects self-referencing edges", () => {
// Self-refs are structurally valid but semantically questionable
const edge = { ...validEdge, fromNodeId: "n1", toNodeId: "n1" };
const result = situationEdgeSchema.safeParse(edge);
expect(result.success).toBe(true); // Structure is valid; semantics checked elsewhere
});
});
describe("situationGraphSchema", () => {
const validGraph = {
centralStatement: "Test graph summary",
nodes: [makeNode({ id: "n1", label: "Node 1" })],
edges: [],
activeUnknownNodeId: null,
resolvedNodeIds: [],
currentSummary: "Initial summary",
};
it("validates a complete valid graph", () => {
const result = situationGraphSchema.safeParse(validGraph);
expect(result.success).toBe(true);
});
it("requires at least one node", () => {
const invalid = { ...validGraph, nodes: [] };
const result = situationGraphSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
it("allows empty edges array", () => {
const graph = { ...validGraph, edges: [] };
const result = situationGraphSchema.safeParse(graph);
expect(result.success).toBe(true);
});
it("rejects missing centralStatement", () => {
const invalid = { ...validGraph, centralStatement: "" };
const result = situationGraphSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
});
describe("graphUpdateSchema", () => {
it("validates empty update (no-op proposal)", () => {
const result = graphUpdateSchema.safeParse({});
expect(result.success).toBe(true);
});
it("validates a complete update", () => {
const node = makeNode({ id: "n2", label: "New Node" });
const edge = makeEdge({ fromNodeId: "n1", toNodeId: "n2" });
const result = graphUpdateSchema.safeParse({
addedNodes: [node],
updatedNodes: [{ nodeId: "n1", newStatus: "resolved", previousStatus: "unknown", reason: "Question answered" }],
addedEdges: [edge],
removedEdgeIds: ["e-old"],
resolvedUnknownNodeIds: ["n2"],
affectedNodeIds: ["n3"],
});
expect(result.success).toBe(true);
});
it("rejects update with invalid node kind in addedNodes", () => {
const invalid = graphUpdateSchema.safeParse({
addedNodes: [{ id: "x", label: "Test", kind: "invalid_kind", description: "test", status: "unknown", confidence: "medium", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [] }],
});
expect(invalid.success).toBe(false);
});
});
describe("API request schemas", () => {
describe("startCaseRequestSchema", () => {
it("validates scenario field", () => {
const result = startCaseRequestSchema.safeParse({ scenario: "Test scenario" });
expect(result.success).toBe(true);
});
it("rejects empty scenario", () => {
const result = startCaseRequestSchema.safeParse({ scenario: "" });
expect(result.success).toBe(false);
});
it("rejects scenario over 10000 chars", () => {
const longScenario = "a".repeat(10001);
const result = startCaseRequestSchema.safeParse({ scenario: longScenario });
expect(result.success).toBe(false);
});
it("accepts optional promptVersion", () => {
const result = startCaseRequestSchema.safeParse({
scenario: "Test",
promptVersion: "v0.3"
});
expect(result.success).toBe(true);
});
});
describe("updateCaseRequestSchema", () => {
it("validates complete update request", () => {
const graph = makeGraph({
centralStatement: "Test scenario",
nodes: [makeNode({ id: "n1", label: "N" })],
currentSummary: "Current state of situation"
});
const result = updateCaseRequestSchema.safeParse({
situationGraph: graph,
previousQuestion: "What happened?",
answer: "This is the answer",
});
expect(result.success).toBe(true);
});
it("rejects missing situationGraph", () => {
const result = updateCaseRequestSchema.safeParse({
previousQuestion: "Q?",
answer: "A",
});
expect(result.success).toBe(false);
});
it("rejects answer over 5000 chars", () => {
const graph = makeGraph({
centralStatement: "Test",
nodes: [makeNode({ id: "n1", label: "N" })],
currentSummary: "Test summary"
});
const result = updateCaseRequestSchema.safeParse({
situationGraph: graph,
previousQuestion: "Q?",
answer: "x".repeat(5001),
});
expect(result.success).toBe(false);
});
});
});
describe("deterministic ID generation", () => {
it("generate consistent IDs for same label", () => {
const id1 = makeNodeId("Same Label");
const id2 = makeNodeId("Same Label");
expect(id1).toBe(id2);
});
it("generates different IDs for different labels", () => {
const id1 = makeNodeId("Label A");
const id2 = makeNodeId("Label B");
expect(id1).not.toBe(id2);
});
it("IDs are prefixed with 'n' and short", () => {
const id = makeNodeId("A very long label that would produce a longer hash if not truncated");
expect(id.startsWith("n")).toBe(true);
expect(id.length).toBeLessThan(15);
});
it("same kind of nodes get deterministic IDs", () => {
for (let i = 0; i < 10; i++) {
expect(makeNodeId("Test Node")).toBe(makeNodeId("Test Node"));
}
});
});
describe("helper functions", () => {
describe("makeNode", () => {
it("creates a minimal node with defaults", () => {
const node = makeNode({ label: "Minimal" });
const result = situationNodeSchema.safeParse(node);
expect(result.success).toBe(true);
expect(node.kind).toBe("observation");
expect(node.status).toBe("unknown");
expect(node.confidence).toBe("medium");
});
it("creates a node with custom kind/status", () => {
const node = makeNode({
label: "Custom",
kind: "metric",
status: "known",
confidence: "high",
value: 42,
unit: "count",
});
expect(node.kind).toBe("metric");
expect(node.status).toBe("known");
expect(node.confidence).toBe("high");
expect(node.value).toBe(42);
expect(node.unit).toBe("count");
});
it("generates ID from label if none provided", () => {
const node = makeNode({ label: "Auto-ID" });
expect(node.id.startsWith("n")).toBe(true);
});
});
describe("makeEdge", () => {
it("creates a minimal edge with defaults", () => {
const edge = makeEdge({ fromNodeId: "n1", toNodeId: "n2" });
const result = situationEdgeSchema.safeParse(edge);
expect(result.success).toBe(true);
});
it("generates description from node ids if not provided", () => {
const edge = makeEdge({ fromNodeId: "n-alpha", toNodeId: "n-beta" });
expect(edge.description).toContain("alpha");
expect(edge.description).toContain("beta");
});
});
describe("makeGraph", () => {
it("creates a minimal graph with defaults", () => {
const graph = makeGraph({
centralStatement: "Test",
currentSummary: "Default summary",
nodes: [makeNode({ id: "n1", label: "Placeholder" })]
});
const result = situationGraphSchema.safeParse(graph);
expect(result.success).toBe(true);
});
it("allows specifying nodes and edges", () => {
const graph = makeGraph({
centralStatement: "Full Graph",
currentSummary: "Full summary",
nodes: [makeNode({ id: "n1", label: "N1" })],
edges: [makeEdge({ fromNodeId: "n1", toNodeId: "n2" })],
});
expect(graph.nodes.length).toBe(1);
expect(graph.edges.length).toBe(1);
});
});
});
describe("enum values completeness", () => {
it("SituationKind has all expected values", () => {
const expected = ["observation", "reported_claim", "metric", "state", "transition", "relationship", "assumption", "unknown", "conclusion"];
const actual = Object.values(SituationKind);
expect(actual).toEqual(expect.arrayContaining(expected));
});
it("SituationStatus has all expected values", () => {
const expected = ["known", "unknown", "provisional", "supported", "weakened", "contradicted", "resolved"];
const actual = Object.values(SituationStatus);
expect(actual).toEqual(expect.arrayContaining(expected));
});
it("SituationRelationship has all expected values", () => {
const expected = ["supports", "weakens", "contradicts", "depends_on", "causes", "may_cause", "measures", "compares_with", "updates", "other"];
const actual = Object.values(SituationRelationship);
expect(actual).toEqual(expect.arrayContaining(expected));
});
it("ConfidenceLevel has all expected values", () => {
const actual = Object.values(ConfidenceLevel);
expect(actual).toContain("low");
expect(actual).toContain("medium");
expect(actual).toContain("high");
});
});
+825
View File
@@ -0,0 +1,825 @@
import { describe, it, expect } from "vitest";
import {
validateGraphReferences,
detectDuplicateNodeIds,
detectDuplicateEdges,
findDependentNodes,
findAffectedNodes,
resolveUnknownNode,
selectActiveUnknownCandidate,
applyGraphUpdate,
validateGraphUpdate,
} from "@/lib/graph/utils.js";
import { makeNode, makeEdge, makeGraph } from "@/lib/graph/schema.js";
// ── Helper: build a minimal graph for tests ───────────
function makeTestGraph() {
const n1 = makeNode({ id: "n1", label: "Actor A" });
const n2 = makeNode({ id: "n2", label: "State B" });
const n3 = makeNode({ id: "n3", label: "Transition C" });
const n4 = makeNode({ id: "n4", label: "Unknown D" });
const n5 = makeNode({ id: "n5", label: "Unknown E" });
// n2 depends on n1; n3 depends on n2 (transitive depends on n1)
n2.dependsOn.push(n1.id);
n3.dependsOn.push(n2.id);
// n4 is an unknown not depended on
// n5 is an unknown depended upon by n3 indirectly
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "depends_on" });
const e2 = makeEdge({ id: "e2", fromNodeId: n3.id, toNodeId: n1.id, relationship: "supports" });
return makeGraph({
centralStatement: "Test graph",
nodes: [n1, n2, n3, n4, n5],
edges: [e1, e2],
activeUnknownNodeId: n4.id,
resolvedNodeIds: [],
currentSummary: "Test",
});
}
describe("validateGraphReferences", () => {
it("accepts valid graph with all self-consistent references", () => {
const graph = makeTestGraph();
const result = validateGraphReferences(graph);
expect(result.valid).toBe(true);
expect(result.errors.length).toBe(0);
});
it("detects invalid parentId reference", () => {
const graph = makeTestGraph();
// n1 has no parentId, so this won't trigger; let's add one manually
graph.nodes[0].parentId = "nonexistent-parent";
const result = validateGraphReferences(graph);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes("nonexistent-parent"))).toBe(true);
});
it("detects invalid childIds reference", () => {
const graph = makeTestGraph();
graph.nodes[0].childIds.push("ghost-node");
const result = validateGraphReferences(graph);
expect(result.valid).toBe(false);
});
it("detects invalid dependsOn reference", () => {
const graph = makeTestGraph();
graph.nodes[0].dependsOn.push("phantom-dep");
const result = validateGraphReferences(graph);
expect(result.valid).toBe(false);
});
it("detects invalid affects reference", () => {
const graph = makeTestGraph();
graph.nodes[0].affects.push("void-node");
const result = validateGraphReferences(graph);
expect(result.valid).toBe(false);
});
it("detects edge referencing non-existent fromNodeId", () => {
const graph = makeTestGraph();
graph.edges[0].fromNodeId = "ghost-node";
const result = validateGraphReferences(graph);
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes("ghost-node"))).toBe(true);
});
it("detects edge referencing non-existent toNodeId", () => {
const graph = makeTestGraph();
graph.edges[0].toNodeId = "void-node";
const result = validateGraphReferences(graph);
expect(result.valid).toBe(false);
});
it("allows mixed valid and invalid references", () => {
const graph = makeTestGraph();
graph.nodes[0].parentId = "missing";
graph.nodes[1].parentId = "also-missing";
const result = validateGraphReferences(graph);
expect(result.valid).toBe(false);
expect(result.errors.length).toBe(2);
});
});
describe("detectDuplicateNodeIds", () => {
it("returns empty for unique nodes", () => {
const graph = makeTestGraph();
const dups = detectDuplicateNodeIds(graph.nodes);
expect(dups.length).toBe(0);
});
it("detects exact duplicate IDs", () => {
const n1 = makeNode({ id: "dup", label: "First" });
const n2 = makeNode({ id: "dup", label: "Second" });
const dups = detectDuplicateNodeIds([n1, n2]);
expect(dups.length).toBe(1);
expect(dups[0].nodeId).toBe("dup");
expect(dups[0].count).toBe(2);
});
it("detects multiple duplicate groups", () => {
const nodes = [
makeNode({ id: "dup", label: "A" }),
makeNode({ id: "dup", label: "B" }),
makeNode({ id: "dup", label: "C" }),
makeNode({ id: "dup2", label: "D" }),
makeNode({ id: "dup2", label: "E" }),
];
const dups = detectDuplicateNodeIds(nodes);
expect(dups.length).toBe(2);
});
it("reports correct count for triple duplicates", () => {
const nodes = [
makeNode({ id: "trip", label: "1" }),
makeNode({ id: "trip", label: "2" }),
makeNode({ id: "trip", label: "3" }),
];
const dups = detectDuplicateNodeIds(nodes);
expect(dups[0].count).toBe(3);
});
});
describe("detectDuplicateEdges", () => {
it("returns empty for unique edges", () => {
const graph = makeTestGraph();
const dups = detectDuplicateEdges(graph.edges);
expect(dups.length).toBe(0);
});
it("detects duplicate edge (same from, to, relationship)", () => {
const n1 = makeNode({ id: "n1", label: "A" });
const n2 = makeNode({ id: "n2", label: "B" });
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
const e2 = makeEdge({ id: "e2", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
const dups = detectDuplicateEdges([e1, e2]);
expect(dups.length).toBe(1);
});
it("allows same nodes with different relationship types", () => {
const n1 = makeNode({ id: "n1", label: "A" });
const n2 = makeNode({ id: "n2", label: "B" });
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
const e2 = makeEdge({ id: "e2", fromNodeId: n1.id, toNodeId: n2.id, relationship: "weakens" });
const dups = detectDuplicateEdges([e1, e2]);
expect(dups.length).toBe(0);
});
it("detects reversed direction as different edge", () => {
const n1 = makeNode({ id: "n1", label: "A" });
const n2 = makeNode({ id: "n2", label: "B" });
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
const e2 = makeEdge({ id: "e2", fromNodeId: n2.id, toNodeId: n1.id, relationship: "supports" });
const dups = detectDuplicateEdges([e1, e2]);
expect(dups.length).toBe(0);
});
});
describe("findDependentNodes (transitive)", () => {
it("returns empty for node with no dependents", () => {
const graph = makeTestGraph();
// n5 has nothing depending on it
const deps = findDependentNodes(graph, "n5");
expect(deps.length).toBe(0);
});
it("finds direct dependents via dependsOn", () => {
const graph = makeTestGraph();
// n2 depends on n1
const deps = findDependentNodes(graph, "n1");
expect(deps).toContain("n2");
});
it("finds transitive dependents via dependsOn chain", () => {
const graph = makeTestGraph();
// n3 depends on n2 depends on n1 — so both n2 and n3 depend on n1
const deps = findDependentNodes(graph, "n1");
expect(deps).toContain("n2");
expect(deps).toContain("n3");
});
it("finds dependents via edge relationship too", () => {
const graph = makeTestGraph();
// e2: n3 -> n1 (supports), so if we query for nodes depending on n1
// the function also looks at edges where toNodeId === queriedId
const deps = findDependentNodes(graph, "n1");
expect(deps).toContain("n2");
});
it("returns self if node depends on itself", () => {
const graph = makeTestGraph();
graph.nodes[0].dependsOn.push("n1"); // n1 depends on n1 (circular)
const deps = findDependentNodes(graph, "n1");
expect(deps).toContain("n1");
});
it("handles deep dependency chains", () => {
const nodes = [];
for (let i = 1; i <= 10; i++) {
nodes.push(makeNode({ id: `n${i}`, label: `N${i}` }));
}
// Chain: n2 depends on n1, n3 depends on n2, ..., n10 depends on n9
for (let i = 2; i <= 10; i++) {
nodes[i - 1].dependsOn.push(nodes[0].id); // All depend on n1
}
const graph = makeGraph({
centralStatement: "Chain",
nodes,
edges: [],
activeUnknownNodeId: null,
resolvedNodeIds: [],
currentSummary: "Test",
});
const deps = findDependentNodes(graph, "n1");
expect(deps.length).toBe(9); // All other nodes depend on n1
});
});
describe("findAffectedNodes (transitive)", () => {
it("returns empty for node that affects nothing", () => {
const graph = makeTestGraph();
const affected = findAffectedNodes(graph, "n5");
expect(affected.length).toBe(0);
});
it("finds nodes listed in affects array", () => {
// Set up: n2 has n3 in its affects list
const graph = makeTestGraph();
graph.nodes[1].affects.push("n3");
const affected = findAffectedNodes(graph, "n2");
expect(affected).toContain("n3");
});
it("propagates through dependsOn transitive chain", () => {
// n3 depends on n2, and n2's affects includes some node that depends on n3
const graph = makeTestGraph();
// If n2 is changed and n3 depends on n2, then n3 should be affected
graph.nodes[2].dependsOn.push("n2"); // Explicit dependency
const affected = findAffectedNodes(graph, "n2");
expect(affected).toContain("n3");
});
it("handles empty graph", () => {
// build a minimal graph without triggering schema validation for this edge case
const graph = { centralStatement: "Empty", nodes: [], edges: [], resolvedNodeIds: [], currentSummary: "", activeUnknownNodeId: null };
const affected = findAffectedNodes(graph, "any-node");
expect(affected.length).toBe(0);
});
});
describe("resolveUnknownNode", () => {
it("returns success for valid node id", () => {
const graph = makeTestGraph();
const result = resolveUnknownNode(graph, "n4", "resolved", "Confirmed", "User confirmed");
expect(result.success).toBe(true);
expect(result.newStatus).toBe("resolved");
expect(result.reason).toBe("User confirmed");
});
it("returns error for non-existent node", () => {
const graph = makeTestGraph();
const result = resolveUnknownNode(graph, "ghost-node", "resolved", null, "reason");
expect(result.success).toBe(false);
expect(result.error).toContain("not found");
});
it("reports affectedNodes in result", () => {
const graph = makeTestGraph();
// n5 depends on... actually let's set up properly
graph.nodes[3].affects.push("n1"); // Unknown depends on Actor A
graph.nodes[3].dependsOn.push("n2"); // Unknown depends on State B
const result = resolveUnknownNode(graph, "n4", "resolved", "Yes", "Clarified");
expect(result.success).toBe(true);
});
it("tracks previous status and value", () => {
const graph = makeTestGraph();
const result = resolveUnknownNode(graph, "n4", "known", "confirmed_value", "Evidence found");
expect(result.previousStatus).toBe("unknown");
expect(result.newValue).toBe("confirmed_value");
});
});
describe("selectActiveUnknownCandidate", () => {
it("returns null when no unresolved unknowns", () => {
// makeTestGraph nodes default to kind "observation", not "unknown"
// Create explicit unknown-kind nodes for this test
const nUnknown = makeNode({ id: "n-unk-x", label: "Unknown X", kind: "unknown" });
const graph = makeGraph({
centralStatement: "Test",
nodes: [nUnknown],
edges: [],
activeUnknownNodeId: null,
resolvedNodeIds: [],
currentSummary: "Test",
});
// Mark it as resolved so no unresolved unknowns remain
const result = selectActiveUnknownCandidate(graph, ["n-unk-x"]);
expect(result).toBeNull();
});
it("skips already-resolved nodes and returns remaining unknown", () => {
const n1 = makeNode({ id: "n1", label: "A", kind: "observation" });
const n2 = makeNode({ id: "n-unk-b", label: "Unknown B", kind: "unknown" });
const graph = makeGraph({
centralStatement: "Test",
nodes: [n1, n2],
edges: [],
activeUnknownNodeId: null,
resolvedNodeIds: [],
currentSummary: "Test",
});
// Skip n2 by passing it as resolved; no unknown-kind nodes remain
const result = selectActiveUnknownCandidate(graph, ["n-unk-b"]);
expect(result).toBeNull();
// Without skipping, should return n2
const result2 = selectActiveUnknownCandidate(graph, []);
expect(result2.nodeId).toBe("n-unk-b");
});
it("prioritises nodes with more dependents", () => {
const unknownA = makeNode({ id: "unknown-a", label: "Unknown A", kind: "unknown" });
const unknownB = makeNode({ id: "unknown-b", label: "Unknown B", kind: "unknown" });
const dependent = makeNode({ id: "dep", label: "Dependent", kind: "state" });
dependent.dependsOn.push("unknown-a");
const graph = makeGraph({
centralStatement: "Priority test",
nodes: [unknownA, unknownB, dependent],
edges: [],
activeUnknownNodeId: null,
resolvedNodeIds: [],
currentSummary: "Test",
});
const result = selectActiveUnknownCandidate(graph, []);
expect(result.nodeId).toBe("unknown-a"); // Has more dependents (score 2 vs 0)
});
it("returns one candidate (not array)", () => {
const n1 = makeNode({ id: "n1", label: "A", kind: "observation" });
const nUnknown = makeNode({ id: "n-unk", label: "Pending", kind: "unknown" });
const graph = makeGraph({
centralStatement: "Test",
nodes: [n1, nUnknown],
edges: [],
activeUnknownNodeId: null,
resolvedNodeIds: [],
currentSummary: "Test",
});
const result = selectActiveUnknownCandidate(graph, []);
expect(typeof result).toBe("object");
expect(result.nodeId).toBeDefined();
expect(result.label).toBeDefined();
expect(result.score).toBeDefined();
});
});
describe("applyGraphUpdate", () => {
it("applies node additions correctly", () => {
const graph = makeTestGraph();
const newNode = makeNode({ id: "n-new", label: "New Node" });
const update = {
addedNodes: [newNode],
updatedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
};
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true);
expect(result.nodes.length).toBe(graph.nodes.length + 1);
expect(result.nodes.some(n => n.id === "n-new")).toBe(true);
});
it("applies status updates correctly", () => {
const graph = makeTestGraph();
const update = {
addedNodes: [],
updatedNodes: [{
nodeId: "n4",
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "confirmed",
reason: "Answered by user",
}],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [],
};
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true);
const updatedNode = result.nodes.find(n => n.id === "n4");
expect(updatedNode.status).toBe("resolved");
});
it("rejects update with non-existent nodeId in updatedNodes", () => {
const graph = makeTestGraph();
const update = {
addedNodes: [],
updatedNodes: [{
nodeId: "ghost-node",
previousStatus: null,
newStatus: "known",
reason: "test",
}],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
};
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(false);
expect(result.errors.some(e => e.includes("ghost-node"))).toBe(true);
});
it("removes requested edges", () => {
const graph = makeTestGraph();
const edgeIdToRemove = graph.edges[0].id;
const update = {
addedNodes: [],
updatedNodes: [],
addedEdges: [],
removedEdgeIds: [edgeIdToRemove],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
};
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true);
expect(result.edges.length).toBe(graph.edges.length - 1);
expect(result.edges.some(e => e.id === edgeIdToRemove)).toBe(false);
});
it("adds edges and updates node dependsOn/affects", () => {
const graph = makeTestGraph();
const newEdge = makeEdge({ fromNodeId: "n1", toNodeId: "n4", relationship: "supports" });
const update = {
addedNodes: [],
updatedNodes: [],
addedEdges: [newEdge],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
};
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true);
// Check the edge was added
expect(result.edges.some(e => e.id === newEdge.id)).toBe(true);
// Check node relationship arrays updated
const fromNode = result.nodes.find(n => n.id === "n1");
const toNode = result.nodes.find(n => n.id === "n4");
expect(fromNode.childIds).toContain("n4");
expect(toNode.dependsOn).toContain("n1");
});
it("accumulates resolved node IDs", () => {
const graph = makeTestGraph();
const update = {
addedNodes: [],
updatedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [],
};
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true);
expect(result.resolvedNodeIds).toContain("n4");
});
it("rejects adding duplicate node IDs", () => {
const graph = makeTestGraph();
const existingNode = graph.nodes[0]; // id: "n1"
// Use the exact same ID as an existing node to create a real duplicate
const update = {
addedNodes: [{ ...existingNode, id: "n1", label: "Dup Node" }],
updatedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
};
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(false);
});
it("rejects edges referencing non-existent nodes", () => {
const graph = makeTestGraph();
const update = {
addedNodes: [],
updatedNodes: [],
addedEdges: [{
id: "e-new",
fromNodeId: "missing-node",
toNodeId: "n1",
relationship: "supports",
confidence: "medium",
description: "bad edge",
}],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
};
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(false);
});
it("preserves nodes not mentioned in the update", () => {
const graph = makeTestGraph();
const unchangedCount = graph.nodes.length;
const update = {
addedNodes: [],
updatedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
};
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true);
expect(result.nodes.length).toBe(unchangedCount);
});
it("applies multiple operations in one update", () => {
const graph = makeTestGraph();
const newNode = makeNode({ id: "n-multi", label: "Multi" });
const update = {
addedNodes: [newNode],
updatedNodes: [{
nodeId: "n4",
previousStatus: "unknown",
newStatus: "resolved",
reason: "Multiple ops test",
}],
addedEdges: [makeEdge({ fromNodeId: "n-multi", toNodeId: "n1" })],
removedEdgeIds: [graph.edges[0]?.id || ""],
resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [],
};
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true);
});
});
describe("validateGraphUpdate", () => {
it("accepts a no-op update with added nodes", () => {
const graph = makeTestGraph();
const newNode = makeNode({ id: "n-new", label: "New" });
const result = validateGraphUpdate(graph, {
addedNodes: [newNode],
updatedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
});
expect(result.valid).toBe(true);
});
it("rejects update with no meaningful change", () => {
const graph = makeTestGraph();
const result = validateGraphUpdate(graph, {
addedNodes: [],
updatedNodes: [{
nodeId: "n1",
previousStatus: null,
newStatus: null,
previousValue: null,
newValue: null,
reason: "No change test",
}],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
});
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes("no meaningful"))).toBe(true);
});
it("rejects duplicate node IDs in additions", () => {
const graph = makeTestGraph();
const existingNode = graph.nodes[0];
const result = validateGraphUpdate(graph, {
addedNodes: [existingNode], // Duplicate ID
updatedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
});
expect(result.valid).toBe(false);
});
it("rejects update to non-existent node", () => {
const graph = makeTestGraph();
const result = validateGraphUpdate(graph, {
addedNodes: [],
updatedNodes: [{
nodeId: "ghost-node",
previousStatus: null,
newStatus: "known",
reason: "test",
}],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
});
expect(result.valid).toBe(false);
});
it("accepts valid status change as meaningful", () => {
const graph = makeTestGraph();
const result = validateGraphUpdate(graph, {
addedNodes: [],
updatedNodes: [{
nodeId: "n4",
previousStatus: "unknown",
newStatus: "known",
reason: "Confirmed",
}],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
});
expect(result.valid).toBe(true);
});
it("rejects oversized update (>100KB)", () => {
const graph = makeTestGraph();
const largeDescription = "x".repeat(150000);
const result = validateGraphUpdate(graph, {
addedNodes: [{ label: largeDescription }], // Will create huge JSON
updatedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
});
expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes("100KB") || e.includes("exceeds"))).toBe(true);
});
it("returns empty errors array for valid update", () => {
const graph = makeTestGraph();
const result = validateGraphUpdate(graph, {
addedNodes: [makeNode({ id: "n-valid", label: "Valid" })],
updatedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
});
expect(result.valid).toBe(true);
expect(result.errors.length).toBe(0);
});
});
// ── Integration: full update lifecycle ───────────────────
describe("update lifecycle integration", () => {
it("complete update cycle: validate → apply → verify", () => {
const graph = makeTestGraph();
// Create a meaningful update
const newNode = makeNode({ id: "n-new", label: "New Discovery" });
const newEdge = makeEdge({ fromNodeId: "n1", toNodeId: "n-new", relationship: "supports" });
// Validate first
const validationResult = validateGraphUpdate(graph, {
addedNodes: [newNode],
updatedNodes: [{
nodeId: "n4",
previousStatus: "unknown",
newStatus: "resolved",
reason: "Answered via follow-up question",
}],
addedEdges: [newEdge],
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [],
});
expect(validationResult.valid).toBe(true);
// Apply
const applyResult = applyGraphUpdate(graph, {
addedNodes: [newNode],
updatedNodes: [{
nodeId: "n4",
previousStatus: "unknown",
newStatus: "resolved",
reason: "Answered via follow-up question",
}],
addedEdges: [newEdge],
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [],
});
expect(applyResult.success).toBe(true);
expect(applyResult.nodes.length).toBe(graph.nodes.length + 1);
expect(applyResult.edges.length).toBe(graph.edges.length + 1);
expect(applyResult.resolvedNodeIds).toContain("n4");
// Verify post-apply integrity
const postValidation = validateGraphReferences(applyResult);
expect(postValidation.valid).toBe(true);
});
it("reject and retry: invalid update should be caught", () => {
const graph = makeTestGraph();
const invalidUpdate = {
addedNodes: [],
updatedNodes: [{ nodeId: "ghost-node", newStatus: "known", reason: "test" }],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
};
// Validation should catch it
expect(validateGraphUpdate(graph, invalidUpdate).valid).toBe(false);
// Apply should also catch it
expect(applyGraphUpdate(graph, invalidUpdate).success).toBe(false);
});
it("preserve unchanged nodes during update", () => {
const graph = makeTestGraph();
const originalNode1 = JSON.parse(JSON.stringify(graph.nodes[0]));
applyGraphUpdate(graph, {
addedNodes: [],
updatedNodes: [{
nodeId: "n4",
previousStatus: "unknown",
newStatus: "resolved",
reason: "Test preserve",
}],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [],
});
// Re-read the graph and check n1 wasn't modified
expect(graph.nodes[0].id).toBe("n1");
expect(graph.nodes[0].status).toBe("unknown"); // unchanged
});
});