582 lines
17 KiB
JavaScript
582 lines
17 KiB
JavaScript
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("projects a supplied dependency between reconstruction source IDs", () => {
|
|
const reconstruction = {
|
|
...makeReconstructionFixture(),
|
|
importantUnknowns: [
|
|
{ id: "u_problem", description: "Whether a quality problem exists", confidence: "high" },
|
|
{ id: "u_intervention", description: "Whether inspection is appropriate", confidence: "high" },
|
|
],
|
|
relationships: [{
|
|
id: "r-dependency",
|
|
fromId: "u_intervention",
|
|
toId: "u_problem",
|
|
relationship: "depends_on",
|
|
description: "Inspection appropriateness depends on the quality problem.",
|
|
confidence: "high",
|
|
}],
|
|
};
|
|
|
|
const result = buildInitialGraph({ reconstruction, evidence: [] });
|
|
const intervention = result.nodes.find((node) => node.label === "Whether inspection is appropriate");
|
|
const problem = result.nodes.find((node) => node.label === "Whether a quality problem exists");
|
|
|
|
expect(result.edges).toContainEqual(expect.objectContaining({
|
|
id: "e-rel-r-dependency",
|
|
fromNodeId: intervention.id,
|
|
toNodeId: problem.id,
|
|
relationship: "depends_on",
|
|
}));
|
|
});
|
|
|
|
it("preserves a supplied compares_with relationship type", () => {
|
|
const reconstruction = {
|
|
...makeReconstructionFixture(),
|
|
observedStates: [
|
|
{ id: "u_a", description: "Complaint rate before", confidence: "high" },
|
|
{ id: "u_b", description: "Complaint rate after", confidence: "high" },
|
|
],
|
|
relationships: [{
|
|
id: "r-compare",
|
|
fromId: "u_a",
|
|
toId: "u_b",
|
|
relationship: "compares_with",
|
|
description: "Compare complaint rates before and after.",
|
|
confidence: "medium",
|
|
}],
|
|
};
|
|
|
|
const result = buildInitialGraph({ reconstruction, evidence: [] });
|
|
expect(result.edges).toContainEqual(expect.objectContaining({
|
|
id: "e-rel-r-compare",
|
|
relationship: "compares_with",
|
|
}));
|
|
});
|
|
|
|
it("skips relationships with invalid source IDs without creating substitute nodes", () => {
|
|
const reconstruction = {
|
|
...makeReconstructionFixture(),
|
|
relationships: [{
|
|
id: "r-missing",
|
|
fromId: "missing-source",
|
|
toId: "obs-1",
|
|
relationship: "depends_on",
|
|
description: "This must be skipped.",
|
|
confidence: "low",
|
|
}],
|
|
};
|
|
|
|
expect(() => buildInitialGraph({ reconstruction, evidence: [] })).not.toThrow();
|
|
const result = buildInitialGraph({ reconstruction, evidence: [] });
|
|
expect(result.edges.find((edge) => edge.id === "e-rel-r-missing")).toBeUndefined();
|
|
expect(result.nodes.find((node) => node.label === "missing-source")).toBeUndefined();
|
|
});
|
|
|
|
it("does not infer direct dependencies between related descriptions without relationships", () => {
|
|
const reconstruction = {
|
|
...makeReconstructionFixture(),
|
|
importantUnknowns: [
|
|
{ id: "u_problem", description: "Whether a quality problem exists", confidence: "high" },
|
|
{ id: "u_intervention", description: "Whether inspection is appropriate", confidence: "high" },
|
|
],
|
|
};
|
|
|
|
const result = buildInitialGraph({ reconstruction, evidence: [] });
|
|
const intervention = result.nodes.find((node) => node.label === "Whether inspection is appropriate");
|
|
const problem = result.nodes.find((node) => node.label === "Whether a quality problem exists");
|
|
|
|
expect(result.edges).not.toContainEqual(expect.objectContaining({
|
|
fromNodeId: intervention.id,
|
|
toNodeId: problem.id,
|
|
relationship: "depends_on",
|
|
}));
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|