895 lines
24 KiB
JavaScript
895 lines
24 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;
|
|
}
|
|
|
|
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" && !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}"`);
|
|
}
|
|
}
|
|
|
|
// 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 ?? null),
|
|
);
|
|
|
|
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 };
|
|
}
|