feat: add situation graph foundation
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user