Files
confidence-engine/lib/graph/utils.js
T

554 lines
16 KiB
JavaScript

/**
* 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";
function normaliseText(value) {
return String(value || "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function collectNodeText(node) {
return `${node?.label || ""} ${node?.description || ""}`.trim();
}
function countIncomingUnknownDependencies(graph, nodeId, resolvedNodeIds) {
const resolvedSet = new Set(resolvedNodeIds || []);
const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
const incoming = new Set();
for (const dependencyId of nodesById.get(nodeId)?.dependsOn || []) {
const dependencyNode = nodesById.get(dependencyId);
if (dependencyNode?.kind === "unknown" && !resolvedSet.has(dependencyId)) {
incoming.add(dependencyId);
}
}
for (const edge of graph.edges) {
if (edge.toNodeId !== nodeId) continue;
const dependencyNode = nodesById.get(edge.fromNodeId);
if (
dependencyNode?.kind === "unknown" &&
!resolvedSet.has(edge.fromNodeId)
) {
incoming.add(edge.fromNodeId);
}
}
return incoming.size;
}
function classifyUnknownPriority(text) {
const normalised = normaliseText(text);
const matches = {
objective:
/\b(objective|goal|outcome|value|problem|job to be done|benefit|commercial value)\b/.test(
normalised,
),
actor:
/\b(customer|user|buyer|actor|stakeholder|audience|recipient)\b/.test(
normalised,
),
criteria:
/\b(success criteria|success threshold|threshold|decision criteria|criterion|justify|sufficient)\b/.test(
normalised,
),
measure:
/\b(metric|measure|measurable|roi|demand|evidence|signal|proof)\b/.test(
normalised,
),
terminology: /\b(define|definition|meaning|means|term|terminology)\b/.test(
normalised,
),
constraint:
/\b(constraint|limit|budget|deadline|requirement|regulation)\b/.test(
normalised,
),
pricing: /\b(price|pricing|price point|subscription|charge|pay for)\b/.test(
normalised,
),
implementation:
/\b(implementation|build approach|architecture|stack|feature|technical design)\b/.test(
normalised,
),
optimisation:
/\b(optimisation|optimi[sz]ation|improve|efficiency|performance|scale)\b/.test(
normalised,
),
speculative:
/\b(maybe|possible|optional|future branch|nice to have|slogan|colour|color|ui)\b/.test(
normalised,
),
};
return matches;
}
export function scoreUnknownCandidate(graph, node, resolvedNodeIds = []) {
const text = collectNodeText(node);
const matches = classifyUnknownPriority(text);
const downstreamCount = findDependentNodes(graph, node.id).length;
const unresolvedParentUnknownCount = countIncomingUnknownDependencies(
graph,
node.id,
resolvedNodeIds,
);
let score = downstreamCount * 4;
if (matches.objective) score += 12;
if (matches.actor) score += 10;
if (matches.criteria) score += 11;
if (matches.measure) score += 8;
if (matches.terminology) score += 7;
if (matches.constraint) score += 9;
if (matches.pricing) score -= 8;
if (matches.implementation) score -= 10;
if (matches.optimisation) score -= 9;
if (matches.speculative) score -= 12;
if (
matches.pricing &&
!matches.objective &&
!matches.criteria &&
!matches.actor
) {
score -= 6;
}
score -= unresolvedParentUnknownCount * 7;
return {
nodeId: node.id,
label: node.label,
score,
downstreamCount,
unresolvedParentUnknownCount,
matches,
};
}
export function buildDeterministicQuestionForUnknown(node) {
const text = normaliseText(collectNodeText(node));
if (
/\b(success criteria|success threshold|threshold|decision criteria|criterion)\b/.test(
text,
)
) {
return `What outcome would define success for ${node.label}?`;
}
if (
/\b(customer|user|buyer|actor|stakeholder|audience|recipient)\b/.test(text)
) {
return `Who is the key actor or customer for ${node.label}?`;
}
if (
/\b(define|definition|meaning|means|term|terminology|value)\b/.test(text)
) {
return `How should ${node.label} be defined for this decision?`;
}
if (
/\b(metric|measure|measurable|roi|demand|evidence|signal|proof)\b/.test(
text,
)
) {
return `What evidence or measure would resolve ${node.label}?`;
}
return `What would resolve ${node.label}?`;
}
// ── 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;
const scoredCandidates = unresolved.map((node) => ({
node,
...scoreUnknownCandidate(graph, node, resolvedNodeIds),
}));
scoredCandidates.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (b.downstreamCount !== a.downstreamCount) {
return b.downstreamCount - a.downstreamCount;
}
if (a.unresolvedParentUnknownCount !== b.unresolvedParentUnknownCount) {
return a.unresolvedParentUnknownCount - b.unresolvedParentUnknownCount;
}
return a.node.label.localeCompare(b.node.label);
});
const best = scoredCandidates[0];
if (!best) return null;
return {
nodeId: best.node.id,
label: best.node.label,
score: best.score,
question: buildDeterministicQuestionForUnknown(best.node),
reason: `Selected for highest information value (score ${best.score}) with ${best.downstreamCount} downstream dependency node(s) and ${best.unresolvedParentUnknownCount} unresolved prerequisite unknown(s).`,
};
}
// ── 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 };
}