feat: add situation graph foundation

This commit is contained in:
2026-08-02 06:59:23 +01:00
parent 3c1362d8a1
commit 0ccc03c111
6 changed files with 2530 additions and 0 deletions
+305
View File
@@ -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(" | ");
}
+191
View File
@@ -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 || "",
});
}
+348
View File
@@ -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 };
}