- Add structuralActionRequired field to graphUpdateSchema (optional boolean nullable) - Validate declaration consistency in validateGraphUpdate(): - true requires meaningful mutation (addedNodes/updatedNodes/addedEdges) - false permits intentional no-op when userSupportedMeaning populated - null/absent with meaning → reject - true/false mismatch on output shape → reject - preserve legacy no-op guard for non-contract paths - Update prompt-builder: add field to required list, insert contract section between rules and Additional Guidance with two mandatory sentences - 50 new tests: schema validation (4), prompt builder content checks (10), utils contract matrix (10), plus 26 existing suite migrations All 197 graph tests pass.
929 lines
26 KiB
JavaScript
929 lines
26 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}"`);
|
|
}
|
|
}
|
|
|
|
// ── 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 !== false.
|
|
// When false → zero-mutation is a valid intentional no-op (contract PASS).
|
|
if (!hasMeaningfulChange && update.structuralActionRequired !== false) {
|
|
if (meaningPopulated) {
|
|
// Avoid double-error when field absence was already flagged above
|
|
if (
|
|
update.structuralActionRequired !== null &&
|
|
update.structuralActionRequired !== undefined
|
|
) {
|
|
errors.push(
|
|
"answerMeaning.userSupportedMeaning is populated, but the proposal contains no graph mutation. answerMeaning alone does not constitute graph progress.",
|
|
);
|
|
}
|
|
} else if (!meaningPopulated) {
|
|
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 };
|
|
}
|