feat: add situation graph foundation
This commit is contained in:
@@ -0,0 +1,305 @@
|
|||||||
|
/**
|
||||||
|
* Deterministic situation graph builder — builds initial graph from scenario text.
|
||||||
|
* Takes v0.2/v0.3 analysis output (from analyseScenario) and constructs a SituationGraph.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
situationNodeSchema,
|
||||||
|
situationEdgeSchema,
|
||||||
|
makeNodeId,
|
||||||
|
} from "./schema.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an initial situation graph from a v0.3 reconstruction result.
|
||||||
|
* @param {{ reconstruction: object, evidence: object[] | undefined }} analysisData
|
||||||
|
* @returns {{ nodes: import("./schema.js").SituationNode[], edges: import("./schema.js").SituationEdge[] }}
|
||||||
|
*/
|
||||||
|
export function buildInitialGraph(analysisData) {
|
||||||
|
const { reconstruction, evidence = [] } = analysisData;
|
||||||
|
|
||||||
|
if (!reconstruction || !reconstruction.summary) {
|
||||||
|
return { nodes: [], edges: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeMap = new Map(); // label -> node
|
||||||
|
|
||||||
|
// ── Helper: register or get a node by label ────────────
|
||||||
|
|
||||||
|
function ensureNode(
|
||||||
|
label,
|
||||||
|
kind,
|
||||||
|
status,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
unit,
|
||||||
|
confidence,
|
||||||
|
) {
|
||||||
|
if (nodeMap.has(label)) return nodeMap.get(label);
|
||||||
|
|
||||||
|
const id = makeNodeId(label);
|
||||||
|
const node = situationNodeSchema.parse({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
description: description ?? label,
|
||||||
|
kind,
|
||||||
|
status,
|
||||||
|
confidence,
|
||||||
|
value: value ?? null,
|
||||||
|
unit: unit ?? null,
|
||||||
|
evidenceIds: [],
|
||||||
|
dependsOn: [],
|
||||||
|
affects: [],
|
||||||
|
parentId: null,
|
||||||
|
childIds: [],
|
||||||
|
});
|
||||||
|
nodeMap.set(label, node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Evidence lookup ────────────────────────────────────
|
||||||
|
|
||||||
|
const evidenceMap = new Map();
|
||||||
|
for (const ev of evidence) {
|
||||||
|
if (ev.id) evidenceMap.set(ev.id, ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEvidenceToNode(nodeId, evidenceId) {
|
||||||
|
const node = Object.values(nodeMap).find((n) => n.id === nodeId);
|
||||||
|
if (node && !node.evidenceIds.includes(evidenceId)) {
|
||||||
|
node.evidenceIds.push(evidenceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Extract observed states as nodes ────────────────────
|
||||||
|
|
||||||
|
const summaryNode = ensureNode(
|
||||||
|
reconstruction.summary || "Situation Summary",
|
||||||
|
"state",
|
||||||
|
"provisional",
|
||||||
|
"Summary of the situation from the scenario text",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"medium",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collect all observable quantities as metric nodes
|
||||||
|
const metrics = new Map();
|
||||||
|
|
||||||
|
if (reconstruction.observedStates) {
|
||||||
|
for (const obs of reconstruction.observedStates) {
|
||||||
|
const node = ensureNode(
|
||||||
|
obs.description || obs.label,
|
||||||
|
"observation",
|
||||||
|
"supported",
|
||||||
|
obs.description || obs.label,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
obs.confidence || "medium",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (obs.id) node.evidenceIds.push(obs.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actors as states/nodes
|
||||||
|
if (reconstruction.actors) {
|
||||||
|
for (const actor of reconstruction.actors) {
|
||||||
|
ensureNode(
|
||||||
|
actor.description || actor.label,
|
||||||
|
"observation",
|
||||||
|
"supported",
|
||||||
|
actor.description || actor.label,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
actor.confidence || "medium",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reconstruction.systemsOrObjects) {
|
||||||
|
for (const sys of reconstruction.systemsOrObjects) {
|
||||||
|
ensureNode(
|
||||||
|
sys.description || sys.label,
|
||||||
|
"metric",
|
||||||
|
"known",
|
||||||
|
sys.description || sys.label,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
sys.confidence || "medium",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Differences as relationship nodes
|
||||||
|
if (reconstruction.differences) {
|
||||||
|
for (const diff of reconstruction.differences) {
|
||||||
|
const node = ensureNode(
|
||||||
|
diff.description || "Difference",
|
||||||
|
"relationship",
|
||||||
|
"supported",
|
||||||
|
diff.description || "Difference",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
diff.confidence || "medium",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contradictions as nodes
|
||||||
|
if (reconstruction.contradictions) {
|
||||||
|
for (const c of reconstruction.contradictions) {
|
||||||
|
const node = ensureNode(
|
||||||
|
c.description || c.label,
|
||||||
|
"relationship",
|
||||||
|
"supported",
|
||||||
|
c.description || c.label,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
c.confidence || "medium",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Important unknowns as unknown nodes
|
||||||
|
const unknownNodes = [];
|
||||||
|
if (reconstruction.importantUnknowns) {
|
||||||
|
for (const unk of reconstruction.importantUnknowns) {
|
||||||
|
const node = ensureNode(
|
||||||
|
unk.description || unk.label,
|
||||||
|
"unknown",
|
||||||
|
"unknown",
|
||||||
|
unk.description || "Unknown factor in the situation",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
unk.confidence || "low",
|
||||||
|
);
|
||||||
|
unknownNodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plausible interpretations
|
||||||
|
if (reconstruction.plausibleInterpretations) {
|
||||||
|
for (const interp of reconstruction.plausibleInterpretations) {
|
||||||
|
ensureNode(
|
||||||
|
interp.description || interp.label,
|
||||||
|
"assumption",
|
||||||
|
"provisional",
|
||||||
|
interp.description || "Plausible interpretation",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
interp.confidence || "low",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known transitions
|
||||||
|
if (reconstruction.knownTransitions) {
|
||||||
|
for (const trans of reconstruction.knownTransitions) {
|
||||||
|
ensureNode(
|
||||||
|
`${trans.entity}: ${trans.previousState} → ${trans.currentState}`,
|
||||||
|
"transition",
|
||||||
|
trans.explanationStatus === "confirmed" ? "known" : "provisional",
|
||||||
|
trans.description ||
|
||||||
|
`Transition: ${trans.entity} from ${trans.previousState} to ${trans.currentState}`,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
trans.confidence || "medium",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build edges between nodes ────────────────────────
|
||||||
|
|
||||||
|
const nodeArr = Array.from(nodeMap.values());
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
// Link actors → observed states as measures relationships
|
||||||
|
let actorNodes = [];
|
||||||
|
let metricNodes = [];
|
||||||
|
let unknownNodeIds = [];
|
||||||
|
|
||||||
|
for (const n of nodeArr) {
|
||||||
|
if (n.kind === "observation" && n.status === "supported") {
|
||||||
|
// These are observations — link to summary
|
||||||
|
edges.push(
|
||||||
|
situationEdgeSchema.parse({
|
||||||
|
id: `e-sum-${n.id}`,
|
||||||
|
fromNodeId: n.id,
|
||||||
|
toNodeId: summaryNode.id,
|
||||||
|
relationship: "supports",
|
||||||
|
confidence: n.confidence || "medium",
|
||||||
|
description: `${n.label} supports the summary`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (n.kind === "unknown") {
|
||||||
|
unknownNodeIds.push(n.id);
|
||||||
|
edges.push(
|
||||||
|
situationEdgeSchema.parse({
|
||||||
|
id: `e-unk-${n.id}`,
|
||||||
|
fromNodeId: n.id,
|
||||||
|
toNodeId: summaryNode.id,
|
||||||
|
relationship: "depends_on",
|
||||||
|
confidence: n.confidence || "low",
|
||||||
|
description: `${n.label} is an unresolved factor for this situation`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes: nodeArr, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a minimal starting graph for any scenario.
|
||||||
|
* Used when analysis has no reconstruction data (e.g., error state).
|
||||||
|
*/
|
||||||
|
export function buildMinimalGraph(scenario) {
|
||||||
|
const shortLabel = scenario.slice(0, 80);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: [
|
||||||
|
situationNodeSchema.parse({
|
||||||
|
id: "n0",
|
||||||
|
label: shortLabel,
|
||||||
|
description: `Initial situation from: "${scenario.slice(0, 200)}"`,
|
||||||
|
kind: "state",
|
||||||
|
status: "provisional",
|
||||||
|
confidence: "low",
|
||||||
|
value: null,
|
||||||
|
unit: null,
|
||||||
|
evidenceIds: [],
|
||||||
|
dependsOn: [],
|
||||||
|
affects: [],
|
||||||
|
parentId: null,
|
||||||
|
childIds: [],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert graph nodes/edges to a human-readable summary for display.
|
||||||
|
*/
|
||||||
|
export function describeGraph(graph) {
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
// Count by kind
|
||||||
|
const byKind = {};
|
||||||
|
for (const n of graph.nodes) {
|
||||||
|
byKind[n.kind] = (byKind[n.kind] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(
|
||||||
|
`Nodes: ${Object.entries(byKind)
|
||||||
|
.map(([k, v]) => `${v} ${k}`)
|
||||||
|
.join(", ")}`,
|
||||||
|
);
|
||||||
|
parts.push(`Edges: ${graph.edges.length} total`);
|
||||||
|
parts.push(
|
||||||
|
`Unknowns: ${graph.nodes.filter((n) => n.status === "unknown").length} unresolved`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return parts.join(" | ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Situation Graph schema — v0.4 experiment.
|
||||||
|
* Defines types for an evolving multi-turn situation reconstruction graph.
|
||||||
|
* Plain TypeScript interfaces implemented as Zod schemas for runtime validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// ── Enums ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const SituationKind = /** @type {const} */ ({
|
||||||
|
observation: "observation",
|
||||||
|
reported_claim: "reported_claim",
|
||||||
|
metric: "metric",
|
||||||
|
state: "state",
|
||||||
|
transition: "transition",
|
||||||
|
relationship: "relationship",
|
||||||
|
assumption: "assumption",
|
||||||
|
unknown: "unknown",
|
||||||
|
conclusion: "conclusion",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SituationStatus = /** @type {const} */ ({
|
||||||
|
known: "known",
|
||||||
|
unknown: "unknown",
|
||||||
|
provisional: "provisional",
|
||||||
|
supported: "supported",
|
||||||
|
weakened: "weakened",
|
||||||
|
contradicted: "contradicted",
|
||||||
|
resolved: "resolved",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ConfidenceLevel = /** @type {const} */ ({
|
||||||
|
low: "low",
|
||||||
|
medium: "medium",
|
||||||
|
high: "high",
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── SituationNode ────────────────────────────────────
|
||||||
|
|
||||||
|
export const situationNodeSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
description: z.string().min(1),
|
||||||
|
kind: z.enum(Object.values(SituationKind)),
|
||||||
|
status: z.enum(Object.values(SituationStatus)),
|
||||||
|
confidence: z.enum(Object.values(ConfidenceLevel)),
|
||||||
|
value: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
|
||||||
|
unit: z.string().nullable().optional(),
|
||||||
|
evidenceIds: z.array(z.string()).default([]),
|
||||||
|
dependsOn: z.array(z.string()).default([]),
|
||||||
|
affects: z.array(z.string()).default([]),
|
||||||
|
parentId: z.string().nullable().optional(),
|
||||||
|
childIds: z.array(z.string()).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @typedef {z.infer<typeof situationNodeSchema>} SituationNode */
|
||||||
|
|
||||||
|
// ── SituationEdge ────────────────────────────────────
|
||||||
|
|
||||||
|
export const SituationRelationship = /** @type {const} */ ({
|
||||||
|
supports: "supports",
|
||||||
|
weakens: "weakens",
|
||||||
|
contradicts: "contradicts",
|
||||||
|
depends_on: "depends_on",
|
||||||
|
causes: "causes",
|
||||||
|
may_cause: "may_cause",
|
||||||
|
measures: "measures",
|
||||||
|
compares_with: "compares_with",
|
||||||
|
updates: "updates",
|
||||||
|
other: "other",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const situationEdgeSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
fromNodeId: z.string().min(1),
|
||||||
|
toNodeId: z.string().min(1),
|
||||||
|
relationship: z.enum(Object.values(SituationRelationship)),
|
||||||
|
confidence: z.enum(Object.values(ConfidenceLevel)),
|
||||||
|
description: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @typedef {z.infer<typeof situationEdgeSchema>} SituationEdge */
|
||||||
|
|
||||||
|
// ── SituationGraph ───────────────────────────────────
|
||||||
|
|
||||||
|
export const situationGraphSchema = z.object({
|
||||||
|
centralStatement: z.string().min(1),
|
||||||
|
nodes: z.array(situationNodeSchema).min(1),
|
||||||
|
edges: z.array(situationEdgeSchema).default([]),
|
||||||
|
activeUnknownNodeId: z.string().nullable(),
|
||||||
|
resolvedNodeIds: z.array(z.string()).default([]),
|
||||||
|
currentSummary: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @typedef {z.infer<typeof situationGraphSchema>} SituationGraph */
|
||||||
|
|
||||||
|
// ── GraphUpdate (change set) ────────────────────────
|
||||||
|
|
||||||
|
const graphUpdateNodeChangeSchema = z.object({
|
||||||
|
nodeId: z.string().min(1),
|
||||||
|
previousStatus: z.enum(Object.values(SituationStatus)).nullable().optional(),
|
||||||
|
newStatus: z.enum(Object.values(SituationStatus)).nullable().optional(),
|
||||||
|
previousValue: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
|
||||||
|
newValue: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
|
||||||
|
reason: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const graphUpdateSchema = z.object({
|
||||||
|
addedNodes: z.array(situationNodeSchema).default([]),
|
||||||
|
updatedNodes: z.array(graphUpdateNodeChangeSchema).default([]),
|
||||||
|
addedEdges: z.array(situationEdgeSchema).default([]),
|
||||||
|
removedEdgeIds: z.array(z.string()).default([]),
|
||||||
|
resolvedUnknownNodeIds: z.array(z.string()).default([]),
|
||||||
|
affectedNodeIds: z.array(z.string()).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** @typedef {z.infer<typeof graphUpdateSchema>} GraphUpdate */
|
||||||
|
|
||||||
|
// ── API request / response schemas ───────────────────
|
||||||
|
|
||||||
|
export const startCaseRequestSchema = z.object({
|
||||||
|
scenario: z.string().min(1).max(10000),
|
||||||
|
promptVersion: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateCaseRequestSchema = z.object({
|
||||||
|
situationGraph: situationGraphSchema,
|
||||||
|
previousQuestion: z.string().min(1),
|
||||||
|
answer: z.string().min(1).max(5000),
|
||||||
|
promptVersion: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Generate a short deterministic ID from a label */
|
||||||
|
export function makeNodeId(label) {
|
||||||
|
return "n" + Math.abs(hashString(label)).toString(36).slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashString(str) {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h = (Math.imul(31, h) + str.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a minimal valid node — used in tests and fixtures */
|
||||||
|
export function makeNode(opts) {
|
||||||
|
const id = opts.id || makeNodeId(opts.label);
|
||||||
|
return situationNodeSchema.parse({
|
||||||
|
id,
|
||||||
|
label: opts.label,
|
||||||
|
description: opts.description ?? opts.label,
|
||||||
|
kind: opts.kind ?? "observation",
|
||||||
|
status: opts.status ?? "unknown",
|
||||||
|
confidence: opts.confidence ?? "medium",
|
||||||
|
value: opts.value ?? null,
|
||||||
|
unit: opts.unit ?? null,
|
||||||
|
evidenceIds: opts.evidenceIds ?? [],
|
||||||
|
dependsOn: opts.dependsOn ?? [],
|
||||||
|
affects: opts.affects ?? [],
|
||||||
|
parentId: opts.parentId ?? null,
|
||||||
|
childIds: opts.childIds ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a minimal valid edge — used in tests and fixtures */
|
||||||
|
export function makeEdge(opts) {
|
||||||
|
return situationEdgeSchema.parse({
|
||||||
|
id: opts.id || "e" + opts.fromNodeId.slice(0,3) + "-" + opts.toNodeId.slice(0,3),
|
||||||
|
fromNodeId: opts.fromNodeId,
|
||||||
|
toNodeId: opts.toNodeId,
|
||||||
|
relationship: opts.relationship ?? "supports",
|
||||||
|
confidence: opts.confidence ?? "medium",
|
||||||
|
description: opts.description ?? opts.fromNodeId + " -> " + opts.toNodeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a minimal valid graph structure */
|
||||||
|
export function makeGraph(opts) {
|
||||||
|
return situationGraphSchema.parse({
|
||||||
|
centralStatement: opts.centralStatement || "",
|
||||||
|
nodes: opts.nodes ?? [],
|
||||||
|
edges: opts.edges ?? [],
|
||||||
|
activeUnknownNodeId: opts.activeUnknownNodeId ?? null,
|
||||||
|
resolvedNodeIds: opts.resolvedNodeIds ?? [],
|
||||||
|
currentSummary: opts.currentSummary || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* Deterministic graph utilities for situation graph operations.
|
||||||
|
* These functions perform safe, validated operations on the graph.
|
||||||
|
* The LLM should never directly modify the graph — it proposes changes,
|
||||||
|
* and these utilities apply them safely.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { situationNodeSchema, situationEdgeSchema, situationGraphSchema } from "./schema.js";
|
||||||
|
|
||||||
|
// ── Validate that all edge references point to existing nodes ──
|
||||||
|
|
||||||
|
export function validateGraphReferences(graph) {
|
||||||
|
const errors = [];
|
||||||
|
const nodeIds = new Set(graph.nodes.map((n) => n.id));
|
||||||
|
|
||||||
|
for (const node of graph.nodes) {
|
||||||
|
if (node.parentId !== null && !nodeIds.has(node.parentId)) {
|
||||||
|
errors.push(`Node "${node.id}" references parentId "${node.parentId}" which does not exist`);
|
||||||
|
}
|
||||||
|
for (const cid of node.childIds) {
|
||||||
|
if (!nodeIds.has(cid)) {
|
||||||
|
errors.push(`Node "${node.id}" references childIds "${cid}" which does not exist`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const dep of node.dependsOn) {
|
||||||
|
if (!nodeIds.has(dep)) {
|
||||||
|
errors.push(`Node "${node.id}" depends on "${dep}" which does not exist`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const aff of node.affects) {
|
||||||
|
if (!nodeIds.has(aff)) {
|
||||||
|
errors.push(`Node "${node.id}" affects "${aff}" which does not exist`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const edge of graph.edges) {
|
||||||
|
if (!nodeIds.has(edge.fromNodeId)) {
|
||||||
|
errors.push(`Edge "${edge.id}" references non-existent fromNodeId "${edge.fromNodeId}"`);
|
||||||
|
}
|
||||||
|
if (!nodeIds.has(edge.toNodeId)) {
|
||||||
|
errors.push(`Edge "${edge.id}" references non-existent toNodeId "${edge.toNodeId}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detect duplicate node IDs ──
|
||||||
|
|
||||||
|
export function detectDuplicateNodeIds(nodes) {
|
||||||
|
const countMap = new Map();
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (countMap.has(node.id)) {
|
||||||
|
countMap.set(node.id, countMap.get(node.id) + 1);
|
||||||
|
} else {
|
||||||
|
countMap.set(node.id, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicates = [];
|
||||||
|
for (const [id, count] of countMap.entries()) {
|
||||||
|
if (count > 1 && !seen.has(id)) {
|
||||||
|
duplicates.push({ nodeId: id, count });
|
||||||
|
seen.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return duplicates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detect duplicate edges ──
|
||||||
|
|
||||||
|
export function detectDuplicateEdges(edges) {
|
||||||
|
const seen = new Set();
|
||||||
|
const duplicates = [];
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
const key = `${edge.fromNodeId}->${edge.toNodeId}:${edge.relationship}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
duplicates.push({ edgeId: edge.id, fromNodeId: edge.fromNodeId, toNodeId: edge.toNodeId, relationship: edge.relationship });
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return duplicates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find all nodes that depend on a given node (transitive) ──
|
||||||
|
|
||||||
|
export function findDependentNodes(graph, nodeId) {
|
||||||
|
const direct = graph.nodes.filter((n) => n.dependsOn.includes(nodeId)).map((n) => n.id);
|
||||||
|
const affected = new Set(direct);
|
||||||
|
|
||||||
|
// Also propagate through edges where the relationship is depends_on
|
||||||
|
for (const edge of graph.edges) {
|
||||||
|
if (edge.toNodeId === nodeId && !affected.has(edge.fromNodeId)) {
|
||||||
|
direct.push(edge.fromNodeId);
|
||||||
|
affected.add(edge.fromNodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transitive propagation — BFS
|
||||||
|
const queue = [...direct];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift();
|
||||||
|
if (!current || !affected.has(current)) continue;
|
||||||
|
|
||||||
|
for (const node of graph.nodes) {
|
||||||
|
if (node.dependsOn.includes(current) && !affected.has(node.id)) {
|
||||||
|
affected.add(node.id);
|
||||||
|
queue.push(node.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...affected];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find all nodes that are directly or indirectly affected by a change in nodeId ──
|
||||||
|
|
||||||
|
export function findAffectedNodes(graph, nodeId) {
|
||||||
|
// Direct effects: two sources
|
||||||
|
// 1. Nodes that depend on this node (they list it in their dependsOn)
|
||||||
|
const directFromDepends = graph.nodes.filter((n) => n.id !== nodeId && n.dependsOn.includes(nodeId)).map((n) => n.id);
|
||||||
|
|
||||||
|
// 2. Targets of the node's affects relationships (this node directly affects them)
|
||||||
|
const myAffectedTargets = new Set(graph.nodes.find((n) => n.id === nodeId)?.affects || []);
|
||||||
|
|
||||||
|
// Merge: also add edge targets where this node is the source
|
||||||
|
for (const edge of graph.edges) {
|
||||||
|
if (edge.fromNodeId === nodeId && !myAffectedTargets.has(edge.toNodeId)) {
|
||||||
|
myAffectedTargets.add(edge.toNodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine both sources
|
||||||
|
const direct = [...new Set([...directFromDepends, ...myAffectedTargets])];
|
||||||
|
|
||||||
|
// Transitive propagation — BFS through dependsOn and affects of affected nodes
|
||||||
|
const affected = new Set(direct);
|
||||||
|
const queue = [...direct];
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift();
|
||||||
|
if (!current || !affected.has(current)) continue;
|
||||||
|
|
||||||
|
for (const node of graph.nodes) {
|
||||||
|
if (node.id !== nodeId && !affected.has(node.id) && (node.dependsOn.includes(current) || node.affects.includes(current))) {
|
||||||
|
affected.add(node.id);
|
||||||
|
queue.push(node.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...affected];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolve an unknown node ──
|
||||||
|
|
||||||
|
export function resolveUnknownNode(graph, nodeId, newStatus, newValue, reason) {
|
||||||
|
const nodeIdx = graph.nodes.findIndex((n) => n.id === nodeId);
|
||||||
|
if (nodeIdx === -1) {
|
||||||
|
return { success: false, error: `Node "${nodeId}" not found in graph` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousStatus = graph.nodes[nodeIdx].status;
|
||||||
|
const previousValue = graph.nodes[nodeIdx].value;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
previousStatus,
|
||||||
|
newStatus,
|
||||||
|
previousValue,
|
||||||
|
newValue,
|
||||||
|
reason,
|
||||||
|
affectedNodes: findAffectedNodes(graph, nodeId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Select the next highest-value active unknown candidate ──
|
||||||
|
|
||||||
|
export function selectActiveUnknownCandidate(graph, resolvedNodeIds) {
|
||||||
|
// Skip already resolved nodes
|
||||||
|
const unresolved = graph.nodes.filter(
|
||||||
|
(n) => n.kind === "unknown" && !resolvedNodeIds.includes(n.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (unresolved.length === 0) return null;
|
||||||
|
|
||||||
|
// Prioritise: critical unknowns first, then those that are depended upon most
|
||||||
|
const dependencyCount = unresolved.map((n) => {
|
||||||
|
const deps = findDependentNodes(graph, n.id).length;
|
||||||
|
const importanceOrder = { critical: 3, important: 2, supporting: 1, incidental: 0 };
|
||||||
|
const impScore = importanceOrder[n.confidence] || 0;
|
||||||
|
return { node: n, score: deps * 2 + impScore };
|
||||||
|
});
|
||||||
|
|
||||||
|
dependencyCount.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
// Return the highest-scoring unresolved unknown
|
||||||
|
const best = dependencyCount[0];
|
||||||
|
if (!best) return null;
|
||||||
|
|
||||||
|
return { nodeId: best.node.id, label: best.node.label, score: best.score };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apply a graph update deterministically ──
|
||||||
|
|
||||||
|
export function applyGraphUpdate(graph, update) {
|
||||||
|
const errors = [];
|
||||||
|
const updatedNodesMap = new Map();
|
||||||
|
|
||||||
|
// Validate that update references existing nodes or newly added ones
|
||||||
|
const allNodeIds = new Set(graph.nodes.map((n) => n.id));
|
||||||
|
for (const added of update.addedNodes) {
|
||||||
|
if (allNodeIds.has(added.id)) {
|
||||||
|
errors.push(`Cannot add node with duplicate ID: "${added.id}"`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
allNodeIds.add(added.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate updated nodes exist
|
||||||
|
for (const upd of update.updatedNodes) {
|
||||||
|
if (!allNodeIds.has(upd.nodeId)) {
|
||||||
|
errors.push(`Cannot update non-existent node: "${upd.nodeId}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate added edges reference existing or new nodes
|
||||||
|
for (const edge of update.addedEdges) {
|
||||||
|
if (!allNodeIds.has(edge.fromNodeId)) {
|
||||||
|
errors.push(`Added edge references non-existent fromNodeId: "${edge.fromNodeId}"`);
|
||||||
|
}
|
||||||
|
if (!allNodeIds.has(edge.toNodeId)) {
|
||||||
|
errors.push(`Added edge references non-existent toNodeId: "${edge.toNodeId}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) return { success: false, errors };
|
||||||
|
|
||||||
|
// Build the new nodes list — start with a deep copy of existing
|
||||||
|
const newNodes = graph.nodes.map((n) => ({ ...n }));
|
||||||
|
|
||||||
|
// Apply updated nodes
|
||||||
|
for (const upd of update.updatedNodes) {
|
||||||
|
const idx = newNodes.findIndex((n) => n.id === upd.nodeId);
|
||||||
|
if (idx === -1) continue; // already validated above
|
||||||
|
|
||||||
|
if (upd.newStatus !== undefined && upd.newStatus !== null) {
|
||||||
|
newNodes[idx].status = upd.newStatus;
|
||||||
|
}
|
||||||
|
if (upd.newValue !== undefined) {
|
||||||
|
newNodes[idx].value = upd.newValue;
|
||||||
|
}
|
||||||
|
updatedNodesMap.set(upd.nodeId, newNodes[idx]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new nodes
|
||||||
|
for (const newNode of update.addedNodes) {
|
||||||
|
if (!allNodeIds.has(newNode.id)) continue;
|
||||||
|
allNodeIds.add(newNode.id);
|
||||||
|
newNodes.push({ ...newNode });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove edges if requested
|
||||||
|
const removedEdgeSet = new Set(update.removedEdgeIds);
|
||||||
|
const newEdges = graph.edges.filter((e) => !removedEdgeSet.has(e.id));
|
||||||
|
|
||||||
|
// Add new edges
|
||||||
|
for (const newEdge of update.addedEdges) {
|
||||||
|
newEdges.push({ ...newEdge });
|
||||||
|
|
||||||
|
// Update dependsOn / affects on the nodes
|
||||||
|
const fromNode = newNodes.find((n) => n.id === newEdge.fromNodeId);
|
||||||
|
const toNode = newNodes.find((n) => n.id === newEdge.toNodeId);
|
||||||
|
if (fromNode && !fromNode.childIds.includes(newEdge.toNodeId)) {
|
||||||
|
fromNode.childIds.push(newEdge.toNodeId);
|
||||||
|
}
|
||||||
|
if (toNode && !toNode.dependsOn.includes(newEdge.fromNodeId)) {
|
||||||
|
toNode.dependsOn.push(newEdge.fromNodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add resolved node IDs
|
||||||
|
const newResolved = [...new Set([...graph.resolvedNodeIds, ...update.resolvedUnknownNodeIds])];
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
nodes: newNodes,
|
||||||
|
edges: newEdges,
|
||||||
|
resolvedNodeIds: newResolved,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validate a proposed graph update before application ──
|
||||||
|
|
||||||
|
export function validateGraphUpdate(graph, update) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
// Check for duplicate node IDs against existing and newly added nodes
|
||||||
|
const extendedIds = new Set(graph.nodes.map((n) => n.id));
|
||||||
|
for (const newNode of update.addedNodes) {
|
||||||
|
if (extendedIds.has(newNode.id)) {
|
||||||
|
errors.push(`Cannot add node with duplicate ID: "${newNode.id}"`);
|
||||||
|
} else {
|
||||||
|
extendedIds.add(newNode.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check updated nodes exist (in original graph, not newly added ones)
|
||||||
|
const existingIds = new Set(graph.nodes.map((n) => n.id));
|
||||||
|
for (const upd of update.updatedNodes) {
|
||||||
|
if (!existingIds.has(upd.nodeId)) {
|
||||||
|
errors.push(`Cannot update non-existent node: "${upd.nodeId}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject updates with no meaningful change
|
||||||
|
const statusChanged = update.updatedNodes.some(
|
||||||
|
(u) => u.previousStatus !== null && u.newStatus !== u.previousStatus
|
||||||
|
);
|
||||||
|
const valueChanged = update.updatedNodes.some(
|
||||||
|
(u) => u.previousValue !== null && u.newValue !== u.previousValue
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasMeaningfulChange =
|
||||||
|
update.addedNodes.length > 0 ||
|
||||||
|
statusChanged ||
|
||||||
|
valueChanged ||
|
||||||
|
update.addedEdges.length > 0 ||
|
||||||
|
update.removedEdgeIds.length > 0;
|
||||||
|
|
||||||
|
if (!hasMeaningfulChange) {
|
||||||
|
errors.push("Update contains no meaningful change");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject oversized input
|
||||||
|
const totalSize = JSON.stringify(update).length;
|
||||||
|
if (totalSize > 100000) {
|
||||||
|
errors.push(`Proposed graph update exceeds 100KB (${totalSize} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user