/** * 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; } function buildScoreContributions( matches, downstreamCount, unresolvedParentUnknownCount, ) { const contributions = [ { rule: "downstream_dependencies", value: downstreamCount, weight: 4, delta: downstreamCount * 4, }, ]; if (matches.objective) { contributions.push({ rule: "objective_match", value: true, weight: 12, delta: 12, }); } if (matches.actor) { contributions.push({ rule: "actor_match", value: true, weight: 10, delta: 10, }); } if (matches.criteria) { contributions.push({ rule: "criteria_match", value: true, weight: 11, delta: 11, }); } if (matches.measure) { contributions.push({ rule: "measure_match", value: true, weight: 8, delta: 8, }); } if (matches.terminology) { contributions.push({ rule: "terminology_match", value: true, weight: 7, delta: 7, }); } if (matches.constraint) { contributions.push({ rule: "constraint_match", value: true, weight: 9, delta: 9, }); } if (matches.pricing) { contributions.push({ rule: "pricing_penalty", value: true, weight: -8, delta: -8, }); } if (matches.implementation) { contributions.push({ rule: "implementation_penalty", value: true, weight: -10, delta: -10, }); } if (matches.optimisation) { contributions.push({ rule: "optimisation_penalty", value: true, weight: -9, delta: -9, }); } if (matches.speculative) { contributions.push({ rule: "speculative_penalty", value: true, weight: -12, delta: -12, }); } if ( matches.pricing && !matches.objective && !matches.criteria && !matches.actor ) { contributions.push({ rule: "isolated_pricing_penalty", value: true, weight: -6, delta: -6, }); } if (unresolvedParentUnknownCount > 0) { contributions.push({ rule: "unresolved_prerequisite_penalty", value: unresolvedParentUnknownCount, weight: -7, delta: unresolvedParentUnknownCount * -7, }); } return contributions; } function getMeaningfulSemanticContributions(contributions = []) { return contributions .filter( (contribution) => contribution.rule !== "downstream_dependencies" && contribution.rule !== "unresolved_prerequisite_penalty" && contribution.delta !== 0, ) .map((contribution) => ({ rule: contribution.rule, delta: contribution.delta, })); } function buildCandidateDisplayOrder(candidates) { return [...candidates].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.label.localeCompare(b.label); }); } function semanticSignature(candidate) { return JSON.stringify( getMeaningfulSemanticContributions(candidate.contributions), ); } function classifyCandidateOrdering(candidates) { const displayOrder = buildCandidateDisplayOrder(candidates); const best = displayOrder[0] ?? null; if (!best) { return { displayOrder, best: null, leadingCandidates: [], status: "no_candidates", tieType: "none", usedAlphabeticalOrdering: false, reason: "No unresolved unknown candidates remain.", }; } const topScoreCandidates = displayOrder.filter( (candidate) => candidate.score === best.score, ); if (topScoreCandidates.length === 1) { return { displayOrder, best, leadingCandidates: [best], status: "selected", tieType: "none", usedAlphabeticalOrdering: false, reason: `Clear winner by total score (${best.score}).`, }; } const topStructuralCandidates = topScoreCandidates.filter( (candidate) => candidate.downstreamCount === best.downstreamCount && candidate.unresolvedParentUnknownCount === best.unresolvedParentUnknownCount, ); if (topStructuralCandidates.length === 1) { return { displayOrder, best, leadingCandidates: [best], status: "selected", tieType: "structural_tie", usedAlphabeticalOrdering: false, reason: "Score tie was resolved by downstream dependency count or prerequisite ordering.", }; } const topSemanticSignature = semanticSignature(best); const semanticPeers = topStructuralCandidates.filter( (candidate) => semanticSignature(candidate) === topSemanticSignature, ); if (semanticPeers.length !== topStructuralCandidates.length) { return { displayOrder, best: null, leadingCandidates: topStructuralCandidates, status: "ambiguous", tieType: "semantic_tie", usedAlphabeticalOrdering: false, reason: "Leading candidates remain tied after score and structural checks, but differ in semantic contribution patterns.", }; } return { displayOrder, best: null, leadingCandidates: topStructuralCandidates, status: "ambiguous", tieType: "complete_unresolved_tie", usedAlphabeticalOrdering: false, reason: "No justified distinction between leading unknowns.", }; } 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, ); const contributions = buildScoreContributions( matches, downstreamCount, unresolvedParentUnknownCount, ); const score = contributions.reduce( (total, contribution) => total + contribution.delta, 0, ); return { nodeId: node.id, label: node.label, score, downstreamCount, unresolvedParentUnknownCount, matches, contributions, }; } 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" && !["known", "resolved", "contradicted"].includes(n.status) && !resolvedNodeIds.includes(n.id), ); if (unresolved.length === 0) return null; const scoredCandidates = unresolved.map((node) => ({ node, ...scoreUnknownCandidate(graph, node, resolvedNodeIds), })); const selection = classifyCandidateOrdering( scoredCandidates.map(({ node, ...candidate }) => ({ ...candidate, node, })), ); if (selection.status === "ambiguous") { return { selectedNode: null, status: "ambiguous", tieType: selection.tieType, tiedCandidateIds: selection.leadingCandidates.map( (candidate) => candidate.nodeId, ), displayOrder: selection.displayOrder.map((candidate) => candidate.nodeId), reason: selection.reason, }; } const best = selection.best; if (!best) return null; return { selectedNode: { nodeId: best.node.id, label: best.node.label, }, status: "selected", tieType: selection.tieType, 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).`, }; } export function explainUnknownSelection(graph, resolvedNodeIds = []) { const unresolved = graph.nodes.filter( (n) => n.kind === "unknown" && !resolvedNodeIds.includes(n.id), ); if (unresolved.length === 0) { return { selectedNodeId: null, selectedNodeLabel: null, status: "no_candidates", tieType: "none", resolvedNodeIds: [...resolvedNodeIds], tiedCandidateIds: [], candidates: [], competitors: [], tieBreakOrder: [ "score_desc", "downstreamCount_desc", "unresolvedParentUnknownCount_asc", "label_asc", ], summary: { candidateCount: 0, }, }; } const candidates = unresolved.map((node) => ({ nodeId: node.id, label: node.label, ...scoreUnknownCandidate(graph, node, resolvedNodeIds), })); const selection = classifyCandidateOrdering(candidates); const orderedCandidates = selection.displayOrder; const selected = selection.best; const competitors = orderedCandidates .filter((candidate) => candidate.nodeId !== selected?.nodeId) .map((candidate) => ({ nodeId: candidate.nodeId, label: candidate.label, score: candidate.score, downstreamCount: candidate.downstreamCount, unresolvedParentUnknownCount: candidate.unresolvedParentUnknownCount, matches: candidate.matches, contributions: candidate.contributions, outrankedBy: { scoreDelta: (selected?.score ?? candidate.score) - candidate.score, downstreamDelta: (selected?.downstreamCount ?? candidate.downstreamCount) - candidate.downstreamCount, unresolvedPrerequisiteDelta: candidate.unresolvedParentUnknownCount - (selected?.unresolvedParentUnknownCount ?? candidate.unresolvedParentUnknownCount), labelOrderWinner: selected && selected.score === candidate.score && selected.downstreamCount === candidate.downstreamCount && selected.unresolvedParentUnknownCount === candidate.unresolvedParentUnknownCount ? selected.label.localeCompare(candidate.label) <= 0 ? selected.label : candidate.label : null, }, })); return { selectedNodeId: selected?.nodeId ?? null, selectedNodeLabel: selected?.label ?? null, status: selection.status, tieType: selection.tieType, resolvedNodeIds: [...resolvedNodeIds], tiedCandidateIds: selection.leadingCandidates.map( (candidate) => candidate.nodeId, ), tieBreakOrder: [ "score_desc", "downstreamCount_desc", "unresolvedParentUnknownCount_asc", "label_asc", ], alphabeticalUsedAsReasoning: false, candidates: orderedCandidates, selected: selected ? { nodeId: selected.nodeId, label: selected.label, score: selected.score, downstreamCount: selected.downstreamCount, unresolvedParentUnknownCount: selected.unresolvedParentUnknownCount, matches: selected.matches, contributions: selected.contributions, } : null, competitors, summary: { candidateCount: orderedCandidates.length, selectedReason: selected ? `highest_score=${selected.score}; downstream=${selected.downstreamCount}; unresolved_prerequisites=${selected.unresolvedParentUnknownCount}` : selection.reason, }, }; } // ── 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}"`); } } // ── structuralActionRequired contract (57J.67) ─────────── const meaningPopulated = !!update.answerMeaning?.userSupportedMeaning; const statusChanged = update.updatedNodes.some( (u) => u.previousStatus !== null && u.newStatus !== u.previousStatus, ); const valueChanged = update.updatedNodes.some( (u) => (u.previousValue ?? null) !== (u.newValue ?? null), ); const hasMeaningfulChange = update.addedNodes.length > 0 || statusChanged || valueChanged || update.addedEdges.length > 0 || update.removedEdgeIds.length > 0; // Missing/null transition rule: must be present when userSupportedMeaning is populated if ( (update.structuralActionRequired === null || update.structuralActionRequired === undefined) && meaningPopulated ) { errors.push( "structuralActionRequired must be present when userSupportedMeaning is populated", ); } // Exact structural claim — four contradiction pairs if (update.structuralActionRequired === true && !hasMeaningfulChange) { errors.push("structuralActionRequired is true but proposal contains no graph mutation"); } if (update.structuralActionRequired === false && hasMeaningfulChange) { errors.push("structuralActionRequired is false but proposal contains meaningful mutations"); } // Legacy no-op guard: only fires when structuralActionRequired is absent (null/undefined). // When true or false → the new contract owns no-op/mutation consistency. // The contract checks above already produced authoritative errors for those cases. const fieldAbsent = update.structuralActionRequired === null || update.structuralActionRequired === undefined; if (!hasMeaningfulChange && fieldAbsent) { if (meaningPopulated) { // structuralActionRequired was missing while userSupportedMeaning exists. // Missing-field rejection already added above; skip semantic-only guard to avoid duplicate errors on the same proposal. } else if (!meaningPopulated) { errors.push("Update contains no meaningful change"); } } // Legacy guard when structuralActionRequired=false: false declares no action needed, // but userSupportedMeaning populated implies semantic intent for change. However the // new-contract checks above already produced an error if there IS mutation. If there's // zero mutation with false + meaning, treat as intentional no-op (contract PASS). // 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 }; }