feat: prioritise follow-up questions by information value

This commit is contained in:
2026-08-02 11:11:55 +01:00
parent 72ef175971
commit 392564ed61
7 changed files with 902 additions and 235 deletions
+38 -12
View File
@@ -2,8 +2,10 @@ import { describeGraph } from "./builder.js";
import { graphUpdateSchema, situationGraphSchema } from "./schema.js"; import { graphUpdateSchema, situationGraphSchema } from "./schema.js";
import { import {
applyGraphUpdate, applyGraphUpdate,
buildDeterministicQuestionForUnknown,
detectDuplicateNodeIds, detectDuplicateNodeIds,
findAffectedNodes, findAffectedNodes,
scoreUnknownCandidate,
selectActiveUnknownCandidate, selectActiveUnknownCandidate,
validateGraphReferences, validateGraphReferences,
validateGraphUpdate, validateGraphUpdate,
@@ -195,6 +197,20 @@ function validateSelectedQuestion(graph, proposal) {
errors.push("selectedQuestion must be a single non-compound question"); errors.push("selectedQuestion must be a single non-compound question");
} }
const resolvedNodeIds = [
...(graph.resolvedNodeIds || []),
...(proposal.resolvedUnknownNodeIds || []),
];
const candidateScore = scoreUnknownCandidate(
{
...graph,
nodes: [...graph.nodes, ...(proposal.addedNodes || [])],
edges: [...graph.edges, ...(proposal.addedEdges || [])],
},
node,
resolvedNodeIds,
);
return { errors, selectedQuestionNodeId: selectedQuestion.nodeId }; return { errors, selectedQuestionNodeId: selectedQuestion.nodeId };
} }
@@ -571,22 +587,32 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
)?.nodeId ?? null; )?.nodeId ?? null;
} }
if ( const deterministicSelection = selectActiveUnknownCandidate(
validatedProposal.selectedQuestion?.nodeId && updatedSituationGraph,
newActiveUnknownNodeId !== validatedProposal.selectedQuestion.nodeId updatedSituationGraph.resolvedNodeIds,
) { );
return {
success: false, if (deterministicSelection?.nodeId) {
stage: "proposal_compatibility", newActiveUnknownNodeId = deterministicSelection.nodeId;
errors: [
`activeUnknownNodeId and selectedQuestion.nodeId disagree: "${newActiveUnknownNodeId}" vs "${validatedProposal.selectedQuestion.nodeId}"`,
],
};
} }
updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId; updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId;
updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph); updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph);
const finalSelectedQuestion = deterministicSelection
? {
nodeId: deterministicSelection.nodeId,
question:
deterministicSelection.question ||
buildDeterministicQuestionForUnknown(
updatedSituationGraph.nodes.find(
(node) => node.id === deterministicSelection.nodeId,
),
),
reason: deterministicSelection.reason,
}
: null;
const resultGraphValidation = situationGraphSchema.safeParse( const resultGraphValidation = situationGraphSchema.safeParse(
updatedSituationGraph, updatedSituationGraph,
); );
@@ -636,7 +662,7 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds, resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds,
previousActiveUnknownNodeId, previousActiveUnknownNodeId,
newActiveUnknownNodeId, newActiveUnknownNodeId,
selectedQuestion: validatedProposal.selectedQuestion, selectedQuestion: finalSelectedQuestion,
changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds), changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds),
graphReferenceValidation: resultReferenceValidation, graphReferenceValidation: resultReferenceValidation,
}; };
+9 -7
View File
@@ -101,15 +101,16 @@ The JSON object must contain exactly these top-level fields:
13a. For every new unknown node, include at least one added edge that connects it to an existing updated/resolved node or to a newly added non-unknown node introduced from the answer. 13a. For every new unknown node, include at least one added edge that connects it to an existing updated/resolved node or to a newly added non-unknown node introduced from the answer.
14. Do not invent evidence. 14. Do not invent evidence.
15. Do not create unsupported causal edges. 15. Do not create unsupported causal edges.
16. Select exactly one new active unknown in selectedQuestion when any consequential unresolved unknown exists. 16. If consequential unresolved unknowns exist, selectedQuestion may identify one valid candidate unknown, but the engine will deterministically choose final priority after validation.
17. selectedQuestion.nodeId must reference an unresolved unknown node that exists either already in the graph or in addedNodes. 17. selectedQuestion.nodeId must reference an unresolved unknown node that exists either already in the graph or in addedNodes.
18. selectedQuestion.question must be one narrow non-compound question about that one unknown. 18. selectedQuestion.question must be one narrow non-compound question about that one unknown.
19. Return selectedQuestion as null only when no consequential unresolved unknown remains. 19. Do not prioritise downstream implementation, pricing, optimisation, or speculative branches ahead of prerequisite definitions, actors, success criteria, constraints, measures, or terminology.
20. Use empty arrays when there are no changes in a category. 20. Return selectedQuestion as null only when no consequential unresolved unknown remains.
21. Never return null array entries. 21. Use empty arrays when there are no changes in a category.
22. Never use unknown enum values. 22. Never return null array entries.
23. Do not change existing IDs. 23. Never use unknown enum values.
24. Do not replace the whole graph, and do not restate unchanged graph content inside the proposal. 24. Do not change existing IDs.
25. Do not replace the whole graph, and do not restate unchanged graph content inside the proposal.
## Additional Guidance ## Additional Guidance
- If the answer only clarifies an existing unknown, prefer updatedNodes and resolvedUnknownNodeIds over creating duplicate nodes. - If the answer only clarifies an existing unknown, prefer updatedNodes and resolvedUnknownNodeIds over creating duplicate nodes.
@@ -117,6 +118,7 @@ The JSON object must contain exactly these top-level fields:
- If the answer creates a more specific decision situation, add the smallest set of new nodes and edges needed to represent that situation and only its most consequential unknowns. - If the answer creates a more specific decision situation, add the smallest set of new nodes and edges needed to represent that situation and only its most consequential unknowns.
- If you add a new unknown, do not leave it floating: connect it with an added edge to the relevant decision/context node created or updated from the answer. - If you add a new unknown, do not leave it floating: connect it with an added edge to the relevant decision/context node created or updated from the answer.
- If you add a new unknown, its description must do two jobs in one sentence: what is unknown, and why resolving it matters for the case. - If you add a new unknown, its description must do two jobs in one sentence: what is unknown, and why resolving it matters for the case.
- Treat selectedQuestion as a candidate only; the engine will apply deterministic information-value scoring after validation.
- If the answer does not justify a change, return empty arrays for every category. - If the answer does not justify a change, return empty arrays for every category.
## Example Constraint Reminder ## Example Constraint Reminder
+267 -62
View File
@@ -5,26 +5,196 @@
* and these utilities apply them safely. * and these utilities apply them safely.
*/ */
import { situationNodeSchema, situationEdgeSchema, situationGraphSchema } from "./schema.js"; 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 ── // ── Validate that all edge references point to existing nodes ──
export function validateGraphReferences(graph) { export function validateGraphReferences(graph) {
const errors = []; const errors = [];
const nodeIds = new Set(graph.nodes.map((n) => n.id)); const nodeIds = new Set(graph.nodes.map((n) => n.id));
for (const node of graph.nodes) { for (const node of graph.nodes) {
if (node.parentId !== null && !nodeIds.has(node.parentId)) { if (node.parentId !== null && !nodeIds.has(node.parentId)) {
errors.push(`Node "${node.id}" references parentId "${node.parentId}" which does not exist`); errors.push(
`Node "${node.id}" references parentId "${node.parentId}" which does not exist`,
);
} }
for (const cid of node.childIds) { for (const cid of node.childIds) {
if (!nodeIds.has(cid)) { if (!nodeIds.has(cid)) {
errors.push(`Node "${node.id}" references childIds "${cid}" which does not exist`); errors.push(
`Node "${node.id}" references childIds "${cid}" which does not exist`,
);
} }
} }
for (const dep of node.dependsOn) { for (const dep of node.dependsOn) {
if (!nodeIds.has(dep)) { if (!nodeIds.has(dep)) {
errors.push(`Node "${node.id}" depends on "${dep}" which does not exist`); errors.push(
`Node "${node.id}" depends on "${dep}" which does not exist`,
);
} }
} }
for (const aff of node.affects) { for (const aff of node.affects) {
@@ -33,16 +203,20 @@ export function validateGraphReferences(graph) {
} }
} }
} }
for (const edge of graph.edges) { for (const edge of graph.edges) {
if (!nodeIds.has(edge.fromNodeId)) { if (!nodeIds.has(edge.fromNodeId)) {
errors.push(`Edge "${edge.id}" references non-existent fromNodeId "${edge.fromNodeId}"`); errors.push(
`Edge "${edge.id}" references non-existent fromNodeId "${edge.fromNodeId}"`,
);
} }
if (!nodeIds.has(edge.toNodeId)) { if (!nodeIds.has(edge.toNodeId)) {
errors.push(`Edge "${edge.id}" references non-existent toNodeId "${edge.toNodeId}"`); errors.push(
`Edge "${edge.id}" references non-existent toNodeId "${edge.toNodeId}"`,
);
} }
} }
return { valid: errors.length === 0, errors }; return { valid: errors.length === 0, errors };
} }
@@ -76,24 +250,31 @@ export function detectDuplicateNodeIds(nodes) {
export function detectDuplicateEdges(edges) { export function detectDuplicateEdges(edges) {
const seen = new Set(); const seen = new Set();
const duplicates = []; const duplicates = [];
for (const edge of edges) { for (const edge of edges) {
const key = `${edge.fromNodeId}->${edge.toNodeId}:${edge.relationship}`; const key = `${edge.fromNodeId}->${edge.toNodeId}:${edge.relationship}`;
if (seen.has(key)) { if (seen.has(key)) {
duplicates.push({ edgeId: edge.id, fromNodeId: edge.fromNodeId, toNodeId: edge.toNodeId, relationship: edge.relationship }); duplicates.push({
edgeId: edge.id,
fromNodeId: edge.fromNodeId,
toNodeId: edge.toNodeId,
relationship: edge.relationship,
});
} }
seen.add(key); seen.add(key);
} }
return duplicates; return duplicates;
} }
// ── Find all nodes that depend on a given node (transitive) ── // ── Find all nodes that depend on a given node (transitive) ──
export function findDependentNodes(graph, nodeId) { export function findDependentNodes(graph, nodeId) {
const direct = graph.nodes.filter((n) => n.dependsOn.includes(nodeId)).map((n) => n.id); const direct = graph.nodes
.filter((n) => n.dependsOn.includes(nodeId))
.map((n) => n.id);
const affected = new Set(direct); const affected = new Set(direct);
// Also propagate through edges where the relationship is depends_on // Also propagate through edges where the relationship is depends_on
for (const edge of graph.edges) { for (const edge of graph.edges) {
if (edge.toNodeId === nodeId && !affected.has(edge.fromNodeId)) { if (edge.toNodeId === nodeId && !affected.has(edge.fromNodeId)) {
@@ -101,13 +282,13 @@ export function findDependentNodes(graph, nodeId) {
affected.add(edge.fromNodeId); affected.add(edge.fromNodeId);
} }
} }
// Transitive propagation — BFS // Transitive propagation — BFS
const queue = [...direct]; const queue = [...direct];
while (queue.length > 0) { while (queue.length > 0) {
const current = queue.shift(); const current = queue.shift();
if (!current || !affected.has(current)) continue; if (!current || !affected.has(current)) continue;
for (const node of graph.nodes) { for (const node of graph.nodes) {
if (node.dependsOn.includes(current) && !affected.has(node.id)) { if (node.dependsOn.includes(current) && !affected.has(node.id)) {
affected.add(node.id); affected.add(node.id);
@@ -115,7 +296,7 @@ export function findDependentNodes(graph, nodeId) {
} }
} }
} }
return [...affected]; return [...affected];
} }
@@ -124,10 +305,14 @@ export function findDependentNodes(graph, nodeId) {
export function findAffectedNodes(graph, nodeId) { export function findAffectedNodes(graph, nodeId) {
// Direct effects: two sources // Direct effects: two sources
// 1. Nodes that depend on this node (they list it in their dependsOn) // 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); 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) // 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 || []); const myAffectedTargets = new Set(
graph.nodes.find((n) => n.id === nodeId)?.affects || [],
);
// Merge: also add edge targets where this node is the source // Merge: also add edge targets where this node is the source
for (const edge of graph.edges) { for (const edge of graph.edges) {
@@ -147,7 +332,11 @@ export function findAffectedNodes(graph, nodeId) {
if (!current || !affected.has(current)) continue; if (!current || !affected.has(current)) continue;
for (const node of graph.nodes) { for (const node of graph.nodes) {
if (node.id !== nodeId && !affected.has(node.id) && (node.dependsOn.includes(current) || node.affects.includes(current))) { if (
node.id !== nodeId &&
!affected.has(node.id) &&
(node.dependsOn.includes(current) || node.affects.includes(current))
) {
affected.add(node.id); affected.add(node.id);
queue.push(node.id); queue.push(node.id);
} }
@@ -164,10 +353,10 @@ export function resolveUnknownNode(graph, nodeId, newStatus, newValue, reason) {
if (nodeIdx === -1) { if (nodeIdx === -1) {
return { success: false, error: `Node "${nodeId}" not found in graph` }; return { success: false, error: `Node "${nodeId}" not found in graph` };
} }
const previousStatus = graph.nodes[nodeIdx].status; const previousStatus = graph.nodes[nodeIdx].status;
const previousValue = graph.nodes[nodeIdx].value; const previousValue = graph.nodes[nodeIdx].value;
return { return {
success: true, success: true,
previousStatus, previousStatus,
@@ -184,26 +373,37 @@ export function resolveUnknownNode(graph, nodeId, newStatus, newValue, reason) {
export function selectActiveUnknownCandidate(graph, resolvedNodeIds) { export function selectActiveUnknownCandidate(graph, resolvedNodeIds) {
// Skip already resolved nodes // Skip already resolved nodes
const unresolved = graph.nodes.filter( const unresolved = graph.nodes.filter(
(n) => n.kind === "unknown" && !resolvedNodeIds.includes(n.id) (n) => n.kind === "unknown" && !resolvedNodeIds.includes(n.id),
); );
if (unresolved.length === 0) return null; if (unresolved.length === 0) return null;
// Prioritise: critical unknowns first, then those that are depended upon most const scoredCandidates = unresolved.map((node) => ({
const dependencyCount = unresolved.map((n) => { node,
const deps = findDependentNodes(graph, n.id).length; ...scoreUnknownCandidate(graph, node, resolvedNodeIds),
const importanceOrder = { critical: 3, important: 2, supporting: 1, incidental: 0 }; }));
const impScore = importanceOrder[n.confidence] || 0;
return { node: n, score: deps * 2 + impScore }; 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);
}); });
dependencyCount.sort((a, b) => b.score - a.score); const best = scoredCandidates[0];
// Return the highest-scoring unresolved unknown
const best = dependencyCount[0];
if (!best) return null; if (!best) return null;
return { nodeId: best.node.id, label: best.node.label, score: best.score }; 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 ── // ── Apply a graph update deterministically ──
@@ -211,7 +411,7 @@ export function selectActiveUnknownCandidate(graph, resolvedNodeIds) {
export function applyGraphUpdate(graph, update) { export function applyGraphUpdate(graph, update) {
const errors = []; const errors = [];
const updatedNodesMap = new Map(); const updatedNodesMap = new Map();
// Validate that update references existing nodes or newly added ones // Validate that update references existing nodes or newly added ones
const allNodeIds = new Set(graph.nodes.map((n) => n.id)); const allNodeIds = new Set(graph.nodes.map((n) => n.id));
for (const added of update.addedNodes) { for (const added of update.addedNodes) {
@@ -221,34 +421,38 @@ export function applyGraphUpdate(graph, update) {
} }
allNodeIds.add(added.id); allNodeIds.add(added.id);
} }
// Validate updated nodes exist // Validate updated nodes exist
for (const upd of update.updatedNodes) { for (const upd of update.updatedNodes) {
if (!allNodeIds.has(upd.nodeId)) { if (!allNodeIds.has(upd.nodeId)) {
errors.push(`Cannot update non-existent node: "${upd.nodeId}"`); errors.push(`Cannot update non-existent node: "${upd.nodeId}"`);
} }
} }
// Validate added edges reference existing or new nodes // Validate added edges reference existing or new nodes
for (const edge of update.addedEdges) { for (const edge of update.addedEdges) {
if (!allNodeIds.has(edge.fromNodeId)) { if (!allNodeIds.has(edge.fromNodeId)) {
errors.push(`Added edge references non-existent fromNodeId: "${edge.fromNodeId}"`); errors.push(
`Added edge references non-existent fromNodeId: "${edge.fromNodeId}"`,
);
} }
if (!allNodeIds.has(edge.toNodeId)) { if (!allNodeIds.has(edge.toNodeId)) {
errors.push(`Added edge references non-existent toNodeId: "${edge.toNodeId}"`); errors.push(
`Added edge references non-existent toNodeId: "${edge.toNodeId}"`,
);
} }
} }
if (errors.length > 0) return { success: false, errors }; if (errors.length > 0) return { success: false, errors };
// Build the new nodes list — start with a deep copy of existing // Build the new nodes list — start with a deep copy of existing
const newNodes = graph.nodes.map((n) => ({ ...n })); const newNodes = graph.nodes.map((n) => ({ ...n }));
// Apply updated nodes // Apply updated nodes
for (const upd of update.updatedNodes) { for (const upd of update.updatedNodes) {
const idx = newNodes.findIndex((n) => n.id === upd.nodeId); const idx = newNodes.findIndex((n) => n.id === upd.nodeId);
if (idx === -1) continue; // already validated above if (idx === -1) continue; // already validated above
if (upd.newStatus !== undefined && upd.newStatus !== null) { if (upd.newStatus !== undefined && upd.newStatus !== null) {
newNodes[idx].status = upd.newStatus; newNodes[idx].status = upd.newStatus;
} }
@@ -257,22 +461,22 @@ export function applyGraphUpdate(graph, update) {
} }
updatedNodesMap.set(upd.nodeId, newNodes[idx]); updatedNodesMap.set(upd.nodeId, newNodes[idx]);
} }
// Add new nodes // Add new nodes
for (const newNode of update.addedNodes) { for (const newNode of update.addedNodes) {
if (!allNodeIds.has(newNode.id)) continue; if (!allNodeIds.has(newNode.id)) continue;
allNodeIds.add(newNode.id); allNodeIds.add(newNode.id);
newNodes.push({ ...newNode }); newNodes.push({ ...newNode });
} }
// Remove edges if requested // Remove edges if requested
const removedEdgeSet = new Set(update.removedEdgeIds); const removedEdgeSet = new Set(update.removedEdgeIds);
const newEdges = graph.edges.filter((e) => !removedEdgeSet.has(e.id)); const newEdges = graph.edges.filter((e) => !removedEdgeSet.has(e.id));
// Add new edges // Add new edges
for (const newEdge of update.addedEdges) { for (const newEdge of update.addedEdges) {
newEdges.push({ ...newEdge }); newEdges.push({ ...newEdge });
// Update dependsOn / affects on the nodes // Update dependsOn / affects on the nodes
const fromNode = newNodes.find((n) => n.id === newEdge.fromNodeId); const fromNode = newNodes.find((n) => n.id === newEdge.fromNodeId);
const toNode = newNodes.find((n) => n.id === newEdge.toNodeId); const toNode = newNodes.find((n) => n.id === newEdge.toNodeId);
@@ -283,10 +487,12 @@ export function applyGraphUpdate(graph, update) {
toNode.dependsOn.push(newEdge.fromNodeId); toNode.dependsOn.push(newEdge.fromNodeId);
} }
} }
// Add resolved node IDs // Add resolved node IDs
const newResolved = [...new Set([...graph.resolvedNodeIds, ...update.resolvedUnknownNodeIds])]; const newResolved = [
...new Set([...graph.resolvedNodeIds, ...update.resolvedUnknownNodeIds]),
];
return { return {
success: true, success: true,
nodes: newNodes, nodes: newNodes,
@@ -299,7 +505,7 @@ export function applyGraphUpdate(graph, update) {
export function validateGraphUpdate(graph, update) { export function validateGraphUpdate(graph, update) {
const errors = []; const errors = [];
// Check for duplicate node IDs against existing and newly added nodes // Check for duplicate node IDs against existing and newly added nodes
const extendedIds = new Set(graph.nodes.map((n) => n.id)); const extendedIds = new Set(graph.nodes.map((n) => n.id));
for (const newNode of update.addedNodes) { for (const newNode of update.addedNodes) {
@@ -309,7 +515,7 @@ export function validateGraphUpdate(graph, update) {
extendedIds.add(newNode.id); extendedIds.add(newNode.id);
} }
} }
// Check updated nodes exist (in original graph, not newly added ones) // Check updated nodes exist (in original graph, not newly added ones)
const existingIds = new Set(graph.nodes.map((n) => n.id)); const existingIds = new Set(graph.nodes.map((n) => n.id));
for (const upd of update.updatedNodes) { for (const upd of update.updatedNodes) {
@@ -317,13 +523,13 @@ export function validateGraphUpdate(graph, update) {
errors.push(`Cannot update non-existent node: "${upd.nodeId}"`); errors.push(`Cannot update non-existent node: "${upd.nodeId}"`);
} }
} }
// Reject updates with no meaningful change // Reject updates with no meaningful change
const statusChanged = update.updatedNodes.some( const statusChanged = update.updatedNodes.some(
(u) => u.previousStatus !== null && u.newStatus !== u.previousStatus (u) => u.previousStatus !== null && u.newStatus !== u.previousStatus,
); );
const valueChanged = update.updatedNodes.some( const valueChanged = update.updatedNodes.some(
(u) => u.previousValue !== null && u.newValue !== u.previousValue (u) => u.previousValue !== null && u.newValue !== u.previousValue,
); );
const hasMeaningfulChange = const hasMeaningfulChange =
@@ -336,13 +542,12 @@ export function validateGraphUpdate(graph, update) {
if (!hasMeaningfulChange) { if (!hasMeaningfulChange) {
errors.push("Update contains no meaningful change"); errors.push("Update contains no meaningful change");
} }
// Reject oversized input // Reject oversized input
const totalSize = JSON.stringify(update).length; const totalSize = JSON.stringify(update).length;
if (totalSize > 100000) { if (totalSize > 100000) {
errors.push(`Proposed graph update exceeds 100KB (${totalSize} bytes)`); errors.push(`Proposed graph update exceeds 100KB (${totalSize} bytes)`);
} }
return { valid: errors.length === 0, errors }; return { valid: errors.length === 0, errors };
} }
+92 -1
View File
@@ -546,7 +546,10 @@ describe("applyValidatedProposal", () => {
), ),
).toBe(true); ).toBe(true);
expect(result.newActiveUnknownNodeId).toBe("n-commercial-value"); expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
expect(result.selectedQuestion).toEqual(proposal.selectedQuestion); expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
expect(result.selectedQuestion?.question).toContain(
"Commercial value definition",
);
}); });
it("rejects more than 3 added unknowns", () => { it("rejects more than 3 added unknowns", () => {
@@ -699,4 +702,92 @@ describe("applyValidatedProposal", () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.newActiveUnknownNodeId).toBe(result.selectedQuestion?.nodeId); expect(result.newActiveUnknownNodeId).toBe(result.selectedQuestion?.nodeId);
}); });
it("replaces downstream pricing question with higher-value commercial-value question", () => {
const { graph, ids } = makeApplicationFixture();
const proposal = {
addedNodes: [
makeNode({
id: "n-commercial-value",
label: "Commercial value definition",
description:
"Need commercial value definition because the decision depends on it.",
kind: "unknown",
status: "unknown",
confidence: "high",
}),
makeNode({
id: "n-pricing",
label: "Target price point",
description:
"Need a price point because revenue assumptions depend on it.",
kind: "unknown",
status: "unknown",
confidence: "medium",
dependsOn: ["n-commercial-value"],
}),
makeNode({
id: "n-build-decision",
label: "Build Confidence Engine decision",
description: "Decision introduced by the answer.",
kind: "state",
status: "supported",
confidence: "medium",
}),
],
updatedNodes: [
{
nodeId: ids.complaintRateUnknown,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Decision whether to build Confidence Engine",
reason: "The answer resolves the original context unknown.",
},
],
addedEdges: [
makeEdge({
id: "e-build-commercial-value",
fromNodeId: "n-build-decision",
toNodeId: "n-commercial-value",
relationship: "depends_on",
confidence: "medium",
description: "The decision depends on defining commercial value.",
}),
makeEdge({
id: "e-commercial-value-pricing",
fromNodeId: "n-commercial-value",
toNodeId: "n-pricing",
relationship: "depends_on",
confidence: "medium",
description: "Pricing depends on commercial value definition.",
}),
makeEdge({
id: "e-build-pricing",
fromNodeId: "n-build-decision",
toNodeId: "n-pricing",
relationship: "depends_on",
confidence: "low",
description: "The decision also references pricing assumptions.",
}),
],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.complaintRateUnknown],
affectedNodeIds: [],
selectedQuestion: {
nodeId: "n-pricing",
question: "What is the target price point?",
reason: "Model chose a downstream leaf.",
},
};
const result = applyValidatedProposal({ situationGraph: graph, proposal });
expect(result.success).toBe(true);
expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
expect(result.selectedQuestion?.question.toLowerCase()).not.toContain(
"price",
);
});
}); });
+80 -5
View File
@@ -582,14 +582,89 @@ describe("lib/graph/orchestrator startCase", () => {
}); });
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.selectedQuestion).toEqual({ expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
nodeId: "n-commercial-value",
question: "How should commercial value be defined for this decision?",
reason: "Consequential unresolved uncertainty remains.",
});
expect(result.newActiveUnknownNodeId).toBe("n-commercial-value"); expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
}); });
it("deterministically prioritises customer value over pricing follow-up", async () => {
const { updateCase } = await import("@/lib/graph/orchestrator.js");
const provider = {
generateReconstruction: vi.fn().mockResolvedValue(
makeProposal({
addedNodes: [
makeNode({
id: "n-value",
label: "Customer value",
description:
"Need customer value because purchase decisions depend on it.",
kind: "unknown",
status: "unknown",
confidence: "high",
}),
makeNode({
id: "n-price",
label: "Target price point",
description:
"Need a target price point because revenue assumptions depend on it.",
kind: "unknown",
status: "unknown",
confidence: "medium",
dependsOn: ["n-value"],
}),
makeNode({
id: "n-decision",
label: "Build Confidence Engine decision",
description: "Decision introduced by the answer.",
kind: "state",
status: "supported",
confidence: "medium",
}),
],
addedEdges: [
{
id: "e-decision-value",
fromNodeId: "n-decision",
toNodeId: "n-value",
relationship: "depends_on",
confidence: "medium",
description: "The decision depends on customer value.",
},
{
id: "e-value-price",
fromNodeId: "n-value",
toNodeId: "n-price",
relationship: "depends_on",
confidence: "medium",
description: "Pricing depends on customer value.",
},
{
id: "e-decision-price",
fromNodeId: "n-decision",
toNodeId: "n-price",
relationship: "depends_on",
confidence: "low",
description: "The decision references pricing assumptions.",
},
],
selectedQuestion: {
nodeId: "n-price",
question: "What is the price point?",
reason: "Model chose pricing.",
},
}),
),
};
const result = await updateCase(makeUpdateRequest(), {
provider,
config: MOCK_CONFIG,
applyProposal: true,
});
expect(result.success).toBe(true);
expect(result.selectedQuestion?.nodeId).toBe("n-value");
});
it("defaults to proposal-only mode", async () => { it("defaults to proposal-only mode", async () => {
const { updateCase } = await import("@/lib/graph/orchestrator.js"); const { updateCase } = await import("@/lib/graph/orchestrator.js");
const applyValidatedProposal = vi.fn(); const applyValidatedProposal = vi.fn();
+3
View File
@@ -107,5 +107,8 @@ describe("buildGraphUpdatePrompt", () => {
expect(prompt).toContain( expect(prompt).toContain(
"selectedQuestion.question must be one narrow non-compound question", "selectedQuestion.question must be one narrow non-compound question",
); );
expect(prompt).toContain(
"the engine will deterministically choose final priority after validation",
);
}); });
}); });
+413 -148
View File
@@ -3,6 +3,7 @@ import {
validateGraphReferences, validateGraphReferences,
detectDuplicateNodeIds, detectDuplicateNodeIds,
detectDuplicateEdges, detectDuplicateEdges,
scoreUnknownCandidate,
findDependentNodes, findDependentNodes,
findAffectedNodes, findAffectedNodes,
resolveUnknownNode, resolveUnknownNode,
@@ -24,12 +25,22 @@ function makeTestGraph() {
// n2 depends on n1; n3 depends on n2 (transitive depends on n1) // n2 depends on n1; n3 depends on n2 (transitive depends on n1)
n2.dependsOn.push(n1.id); n2.dependsOn.push(n1.id);
n3.dependsOn.push(n2.id); n3.dependsOn.push(n2.id);
// n4 is an unknown not depended on // n4 is an unknown not depended on
// n5 is an unknown depended upon by n3 indirectly // n5 is an unknown depended upon by n3 indirectly
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "depends_on" }); const e1 = makeEdge({
const e2 = makeEdge({ id: "e2", fromNodeId: n3.id, toNodeId: n1.id, relationship: "supports" }); id: "e1",
fromNodeId: n1.id,
toNodeId: n2.id,
relationship: "depends_on",
});
const e2 = makeEdge({
id: "e2",
fromNodeId: n3.id,
toNodeId: n1.id,
relationship: "supports",
});
return makeGraph({ return makeGraph({
centralStatement: "Test graph", centralStatement: "Test graph",
@@ -55,7 +66,9 @@ describe("validateGraphReferences", () => {
graph.nodes[0].parentId = "nonexistent-parent"; graph.nodes[0].parentId = "nonexistent-parent";
const result = validateGraphReferences(graph); const result = validateGraphReferences(graph);
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes("nonexistent-parent"))).toBe(true); expect(result.errors.some((e) => e.includes("nonexistent-parent"))).toBe(
true,
);
}); });
it("detects invalid childIds reference", () => { it("detects invalid childIds reference", () => {
@@ -84,7 +97,7 @@ describe("validateGraphReferences", () => {
graph.edges[0].fromNodeId = "ghost-node"; graph.edges[0].fromNodeId = "ghost-node";
const result = validateGraphReferences(graph); const result = validateGraphReferences(graph);
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes("ghost-node"))).toBe(true); expect(result.errors.some((e) => e.includes("ghost-node"))).toBe(true);
}); });
it("detects edge referencing non-existent toNodeId", () => { it("detects edge referencing non-existent toNodeId", () => {
@@ -98,7 +111,7 @@ describe("validateGraphReferences", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
graph.nodes[0].parentId = "missing"; graph.nodes[0].parentId = "missing";
graph.nodes[1].parentId = "also-missing"; graph.nodes[1].parentId = "also-missing";
const result = validateGraphReferences(graph); const result = validateGraphReferences(graph);
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
expect(result.errors.length).toBe(2); expect(result.errors.length).toBe(2);
@@ -154,9 +167,19 @@ describe("detectDuplicateEdges", () => {
it("detects duplicate edge (same from, to, relationship)", () => { it("detects duplicate edge (same from, to, relationship)", () => {
const n1 = makeNode({ id: "n1", label: "A" }); const n1 = makeNode({ id: "n1", label: "A" });
const n2 = makeNode({ id: "n2", label: "B" }); const n2 = makeNode({ id: "n2", label: "B" });
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" }); const e1 = makeEdge({
const e2 = makeEdge({ id: "e2", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" }); id: "e1",
fromNodeId: n1.id,
toNodeId: n2.id,
relationship: "supports",
});
const e2 = makeEdge({
id: "e2",
fromNodeId: n1.id,
toNodeId: n2.id,
relationship: "supports",
});
const dups = detectDuplicateEdges([e1, e2]); const dups = detectDuplicateEdges([e1, e2]);
expect(dups.length).toBe(1); expect(dups.length).toBe(1);
}); });
@@ -164,9 +187,19 @@ describe("detectDuplicateEdges", () => {
it("allows same nodes with different relationship types", () => { it("allows same nodes with different relationship types", () => {
const n1 = makeNode({ id: "n1", label: "A" }); const n1 = makeNode({ id: "n1", label: "A" });
const n2 = makeNode({ id: "n2", label: "B" }); const n2 = makeNode({ id: "n2", label: "B" });
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" }); const e1 = makeEdge({
const e2 = makeEdge({ id: "e2", fromNodeId: n1.id, toNodeId: n2.id, relationship: "weakens" }); id: "e1",
fromNodeId: n1.id,
toNodeId: n2.id,
relationship: "supports",
});
const e2 = makeEdge({
id: "e2",
fromNodeId: n1.id,
toNodeId: n2.id,
relationship: "weakens",
});
const dups = detectDuplicateEdges([e1, e2]); const dups = detectDuplicateEdges([e1, e2]);
expect(dups.length).toBe(0); expect(dups.length).toBe(0);
}); });
@@ -174,9 +207,19 @@ describe("detectDuplicateEdges", () => {
it("detects reversed direction as different edge", () => { it("detects reversed direction as different edge", () => {
const n1 = makeNode({ id: "n1", label: "A" }); const n1 = makeNode({ id: "n1", label: "A" });
const n2 = makeNode({ id: "n2", label: "B" }); const n2 = makeNode({ id: "n2", label: "B" });
const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" }); const e1 = makeEdge({
const e2 = makeEdge({ id: "e2", fromNodeId: n2.id, toNodeId: n1.id, relationship: "supports" }); id: "e1",
fromNodeId: n1.id,
toNodeId: n2.id,
relationship: "supports",
});
const e2 = makeEdge({
id: "e2",
fromNodeId: n2.id,
toNodeId: n1.id,
relationship: "supports",
});
const dups = detectDuplicateEdges([e1, e2]); const dups = detectDuplicateEdges([e1, e2]);
expect(dups.length).toBe(0); expect(dups.length).toBe(0);
}); });
@@ -229,7 +272,7 @@ describe("findDependentNodes (transitive)", () => {
for (let i = 2; i <= 10; i++) { for (let i = 2; i <= 10; i++) {
nodes[i - 1].dependsOn.push(nodes[0].id); // All depend on n1 nodes[i - 1].dependsOn.push(nodes[0].id); // All depend on n1
} }
const graph = makeGraph({ const graph = makeGraph({
centralStatement: "Chain", centralStatement: "Chain",
nodes, nodes,
@@ -270,7 +313,14 @@ describe("findAffectedNodes (transitive)", () => {
it("handles empty graph", () => { it("handles empty graph", () => {
// build a minimal graph without triggering schema validation for this edge case // build a minimal graph without triggering schema validation for this edge case
const graph = { centralStatement: "Empty", nodes: [], edges: [], resolvedNodeIds: [], currentSummary: "", activeUnknownNodeId: null }; const graph = {
centralStatement: "Empty",
nodes: [],
edges: [],
resolvedNodeIds: [],
currentSummary: "",
activeUnknownNodeId: null,
};
const affected = findAffectedNodes(graph, "any-node"); const affected = findAffectedNodes(graph, "any-node");
expect(affected.length).toBe(0); expect(affected.length).toBe(0);
}); });
@@ -279,7 +329,13 @@ describe("findAffectedNodes (transitive)", () => {
describe("resolveUnknownNode", () => { describe("resolveUnknownNode", () => {
it("returns success for valid node id", () => { it("returns success for valid node id", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const result = resolveUnknownNode(graph, "n4", "resolved", "Confirmed", "User confirmed"); const result = resolveUnknownNode(
graph,
"n4",
"resolved",
"Confirmed",
"User confirmed",
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.newStatus).toBe("resolved"); expect(result.newStatus).toBe("resolved");
expect(result.reason).toBe("User confirmed"); expect(result.reason).toBe("User confirmed");
@@ -287,7 +343,13 @@ describe("resolveUnknownNode", () => {
it("returns error for non-existent node", () => { it("returns error for non-existent node", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const result = resolveUnknownNode(graph, "ghost-node", "resolved", null, "reason"); const result = resolveUnknownNode(
graph,
"ghost-node",
"resolved",
null,
"reason",
);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain("not found"); expect(result.error).toContain("not found");
}); });
@@ -297,13 +359,25 @@ describe("resolveUnknownNode", () => {
// n5 depends on... actually let's set up properly // n5 depends on... actually let's set up properly
graph.nodes[3].affects.push("n1"); // Unknown depends on Actor A graph.nodes[3].affects.push("n1"); // Unknown depends on Actor A
graph.nodes[3].dependsOn.push("n2"); // Unknown depends on State B graph.nodes[3].dependsOn.push("n2"); // Unknown depends on State B
const result = resolveUnknownNode(graph, "n4", "resolved", "Yes", "Clarified"); const result = resolveUnknownNode(
graph,
"n4",
"resolved",
"Yes",
"Clarified",
);
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
it("tracks previous status and value", () => { it("tracks previous status and value", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const result = resolveUnknownNode(graph, "n4", "known", "confirmed_value", "Evidence found"); const result = resolveUnknownNode(
graph,
"n4",
"known",
"confirmed_value",
"Evidence found",
);
expect(result.previousStatus).toBe("unknown"); expect(result.previousStatus).toBe("unknown");
expect(result.newValue).toBe("confirmed_value"); expect(result.newValue).toBe("confirmed_value");
}); });
@@ -313,7 +387,11 @@ describe("selectActiveUnknownCandidate", () => {
it("returns null when no unresolved unknowns", () => { it("returns null when no unresolved unknowns", () => {
// makeTestGraph nodes default to kind "observation", not "unknown" // makeTestGraph nodes default to kind "observation", not "unknown"
// Create explicit unknown-kind nodes for this test // Create explicit unknown-kind nodes for this test
const nUnknown = makeNode({ id: "n-unk-x", label: "Unknown X", kind: "unknown" }); const nUnknown = makeNode({
id: "n-unk-x",
label: "Unknown X",
kind: "unknown",
});
const graph = makeGraph({ const graph = makeGraph({
centralStatement: "Test", centralStatement: "Test",
nodes: [nUnknown], nodes: [nUnknown],
@@ -349,9 +427,21 @@ describe("selectActiveUnknownCandidate", () => {
}); });
it("prioritises nodes with more dependents", () => { it("prioritises nodes with more dependents", () => {
const unknownA = makeNode({ id: "unknown-a", label: "Unknown A", kind: "unknown" }); const unknownA = makeNode({
const unknownB = makeNode({ id: "unknown-b", label: "Unknown B", kind: "unknown" }); id: "unknown-a",
const dependent = makeNode({ id: "dep", label: "Dependent", kind: "state" }); label: "Unknown A",
kind: "unknown",
});
const unknownB = makeNode({
id: "unknown-b",
label: "Unknown B",
kind: "unknown",
});
const dependent = makeNode({
id: "dep",
label: "Dependent",
kind: "state",
});
dependent.dependsOn.push("unknown-a"); dependent.dependsOn.push("unknown-a");
@@ -370,7 +460,11 @@ describe("selectActiveUnknownCandidate", () => {
it("returns one candidate (not array)", () => { it("returns one candidate (not array)", () => {
const n1 = makeNode({ id: "n1", label: "A", kind: "observation" }); const n1 = makeNode({ id: "n1", label: "A", kind: "observation" });
const nUnknown = makeNode({ id: "n-unk", label: "Pending", kind: "unknown" }); const nUnknown = makeNode({
id: "n-unk",
label: "Pending",
kind: "unknown",
});
const graph = makeGraph({ const graph = makeGraph({
centralStatement: "Test", centralStatement: "Test",
nodes: [n1, nUnknown], nodes: [n1, nUnknown],
@@ -386,13 +480,152 @@ describe("selectActiveUnknownCandidate", () => {
expect(result.label).toBeDefined(); expect(result.label).toBeDefined();
expect(result.score).toBeDefined(); expect(result.score).toBeDefined();
}); });
it("commercial value wins over pricing", () => {
const commercialValue = makeNode({
id: "n-commercial-value",
label: "Commercial value definition",
description:
"Need to define commercial value because the decision depends on it.",
kind: "unknown",
status: "unknown",
confidence: "high",
});
const pricing = makeNode({
id: "n-pricing",
label: "Target price point",
description:
"Need a target price point because revenue assumptions depend on it.",
kind: "unknown",
status: "unknown",
confidence: "medium",
dependsOn: ["n-commercial-value"],
});
const decision = makeNode({
id: "n-decision",
label: "Build decision",
description: "Decision context",
kind: "state",
status: "supported",
confidence: "medium",
dependsOn: ["n-commercial-value", "n-pricing"],
});
const graph = makeGraph({
centralStatement: "Build decision",
nodes: [commercialValue, pricing, decision],
edges: [],
activeUnknownNodeId: pricing.id,
resolvedNodeIds: [],
currentSummary: "Test",
});
const result = selectActiveUnknownCandidate(graph, []);
expect(result.nodeId).toBe("n-commercial-value");
});
it("customer value wins over UI colour", () => {
const customerValue = makeNode({
id: "n-customer-value",
label: "Customer value",
description:
"Need to know customer value because adoption depends on it.",
kind: "unknown",
status: "unknown",
confidence: "high",
});
const uiColour = makeNode({
id: "n-ui-colour",
label: "UI colour",
description: "Need a UI colour because presentation choices remain open.",
kind: "unknown",
status: "unknown",
confidence: "low",
});
const graph = makeGraph({
centralStatement: "Value question",
nodes: [customerValue, uiColour],
edges: [],
activeUnknownNodeId: null,
resolvedNodeIds: [],
currentSummary: "Test",
});
const result = selectActiveUnknownCandidate(graph, []);
expect(result.nodeId).toBe("n-customer-value");
});
it("success criteria wins over marketing slogan", () => {
const successCriteria = makeNode({
id: "n-success-criteria",
label: "Success criteria",
description:
"Need success criteria because the decision requires a threshold.",
kind: "unknown",
status: "unknown",
confidence: "high",
});
const slogan = makeNode({
id: "n-slogan",
label: "Marketing slogan",
description: "Need a slogan because messaging is undecided.",
kind: "unknown",
status: "unknown",
confidence: "low",
});
const graph = makeGraph({
centralStatement: "Threshold question",
nodes: [successCriteria, slogan],
edges: [],
activeUnknownNodeId: null,
resolvedNodeIds: [],
currentSummary: "Test",
});
const result = selectActiveUnknownCandidate(graph, []);
expect(result.nodeId).toBe("n-success-criteria");
});
it("penalises unknowns with unresolved parent unknowns", () => {
const parentUnknown = makeNode({
id: "n-parent",
label: "Commercial value definition",
description:
"Need commercial value definition because the decision depends on it.",
kind: "unknown",
status: "unknown",
confidence: "high",
});
const childUnknown = makeNode({
id: "n-child",
label: "Target price point",
description: "Need price point because revenue assumptions depend on it.",
kind: "unknown",
status: "unknown",
confidence: "medium",
dependsOn: ["n-parent"],
});
const graph = makeGraph({
centralStatement: "Dependency ordering",
nodes: [parentUnknown, childUnknown],
edges: [],
activeUnknownNodeId: null,
resolvedNodeIds: [],
currentSummary: "Test",
});
const parentScore = scoreUnknownCandidate(graph, parentUnknown, []);
const childScore = scoreUnknownCandidate(graph, childUnknown, []);
expect(parentScore.score).toBeGreaterThan(childScore.score);
});
}); });
describe("applyGraphUpdate", () => { describe("applyGraphUpdate", () => {
it("applies node additions correctly", () => { it("applies node additions correctly", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const newNode = makeNode({ id: "n-new", label: "New Node" }); const newNode = makeNode({ id: "n-new", label: "New Node" });
const update = { const update = {
addedNodes: [newNode], addedNodes: [newNode],
updatedNodes: [], updatedNodes: [],
@@ -401,65 +634,69 @@ describe("applyGraphUpdate", () => {
resolvedUnknownNodeIds: [], resolvedUnknownNodeIds: [],
affectedNodeIds: [], affectedNodeIds: [],
}; };
const result = applyGraphUpdate(graph, update); const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.nodes.length).toBe(graph.nodes.length + 1); expect(result.nodes.length).toBe(graph.nodes.length + 1);
expect(result.nodes.some(n => n.id === "n-new")).toBe(true); expect(result.nodes.some((n) => n.id === "n-new")).toBe(true);
}); });
it("applies status updates correctly", () => { it("applies status updates correctly", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const update = { const update = {
addedNodes: [], addedNodes: [],
updatedNodes: [{ updatedNodes: [
nodeId: "n4", {
previousStatus: "unknown", nodeId: "n4",
newStatus: "resolved", previousStatus: "unknown",
previousValue: null, newStatus: "resolved",
newValue: "confirmed", previousValue: null,
reason: "Answered by user", newValue: "confirmed",
}], reason: "Answered by user",
},
],
addedEdges: [], addedEdges: [],
removedEdgeIds: [], removedEdgeIds: [],
resolvedUnknownNodeIds: ["n4"], resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [], affectedNodeIds: [],
}; };
const result = applyGraphUpdate(graph, update); const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true); expect(result.success).toBe(true);
const updatedNode = result.nodes.find(n => n.id === "n4"); const updatedNode = result.nodes.find((n) => n.id === "n4");
expect(updatedNode.status).toBe("resolved"); expect(updatedNode.status).toBe("resolved");
}); });
it("rejects update with non-existent nodeId in updatedNodes", () => { it("rejects update with non-existent nodeId in updatedNodes", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const update = { const update = {
addedNodes: [], addedNodes: [],
updatedNodes: [{ updatedNodes: [
nodeId: "ghost-node", {
previousStatus: null, nodeId: "ghost-node",
newStatus: "known", previousStatus: null,
reason: "test", newStatus: "known",
}], reason: "test",
},
],
addedEdges: [], addedEdges: [],
removedEdgeIds: [], removedEdgeIds: [],
resolvedUnknownNodeIds: [], resolvedUnknownNodeIds: [],
affectedNodeIds: [], affectedNodeIds: [],
}; };
const result = applyGraphUpdate(graph, update); const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.errors.some(e => e.includes("ghost-node"))).toBe(true); expect(result.errors.some((e) => e.includes("ghost-node"))).toBe(true);
}); });
it("removes requested edges", () => { it("removes requested edges", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const edgeIdToRemove = graph.edges[0].id; const edgeIdToRemove = graph.edges[0].id;
const update = { const update = {
addedNodes: [], addedNodes: [],
updatedNodes: [], updatedNodes: [],
@@ -468,17 +705,21 @@ describe("applyGraphUpdate", () => {
resolvedUnknownNodeIds: [], resolvedUnknownNodeIds: [],
affectedNodeIds: [], affectedNodeIds: [],
}; };
const result = applyGraphUpdate(graph, update); const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.edges.length).toBe(graph.edges.length - 1); expect(result.edges.length).toBe(graph.edges.length - 1);
expect(result.edges.some(e => e.id === edgeIdToRemove)).toBe(false); expect(result.edges.some((e) => e.id === edgeIdToRemove)).toBe(false);
}); });
it("adds edges and updates node dependsOn/affects", () => { it("adds edges and updates node dependsOn/affects", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const newEdge = makeEdge({ fromNodeId: "n1", toNodeId: "n4", relationship: "supports" }); const newEdge = makeEdge({
fromNodeId: "n1",
toNodeId: "n4",
relationship: "supports",
});
const update = { const update = {
addedNodes: [], addedNodes: [],
updatedNodes: [], updatedNodes: [],
@@ -487,23 +728,23 @@ describe("applyGraphUpdate", () => {
resolvedUnknownNodeIds: [], resolvedUnknownNodeIds: [],
affectedNodeIds: [], affectedNodeIds: [],
}; };
const result = applyGraphUpdate(graph, update); const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true); expect(result.success).toBe(true);
// Check the edge was added // Check the edge was added
expect(result.edges.some(e => e.id === newEdge.id)).toBe(true); expect(result.edges.some((e) => e.id === newEdge.id)).toBe(true);
// Check node relationship arrays updated // Check node relationship arrays updated
const fromNode = result.nodes.find(n => n.id === "n1"); const fromNode = result.nodes.find((n) => n.id === "n1");
const toNode = result.nodes.find(n => n.id === "n4"); const toNode = result.nodes.find((n) => n.id === "n4");
expect(fromNode.childIds).toContain("n4"); expect(fromNode.childIds).toContain("n4");
expect(toNode.dependsOn).toContain("n1"); expect(toNode.dependsOn).toContain("n1");
}); });
it("accumulates resolved node IDs", () => { it("accumulates resolved node IDs", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const update = { const update = {
addedNodes: [], addedNodes: [],
updatedNodes: [], updatedNodes: [],
@@ -512,7 +753,7 @@ describe("applyGraphUpdate", () => {
resolvedUnknownNodeIds: ["n4"], resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [], affectedNodeIds: [],
}; };
const result = applyGraphUpdate(graph, update); const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.resolvedNodeIds).toContain("n4"); expect(result.resolvedNodeIds).toContain("n4");
@@ -538,23 +779,25 @@ describe("applyGraphUpdate", () => {
it("rejects edges referencing non-existent nodes", () => { it("rejects edges referencing non-existent nodes", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const update = { const update = {
addedNodes: [], addedNodes: [],
updatedNodes: [], updatedNodes: [],
addedEdges: [{ addedEdges: [
id: "e-new", {
fromNodeId: "missing-node", id: "e-new",
toNodeId: "n1", fromNodeId: "missing-node",
relationship: "supports", toNodeId: "n1",
confidence: "medium", relationship: "supports",
description: "bad edge", confidence: "medium",
}], description: "bad edge",
},
],
removedEdgeIds: [], removedEdgeIds: [],
resolvedUnknownNodeIds: [], resolvedUnknownNodeIds: [],
affectedNodeIds: [], affectedNodeIds: [],
}; };
const result = applyGraphUpdate(graph, update); const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(false); expect(result.success).toBe(false);
}); });
@@ -562,7 +805,7 @@ describe("applyGraphUpdate", () => {
it("preserves nodes not mentioned in the update", () => { it("preserves nodes not mentioned in the update", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const unchangedCount = graph.nodes.length; const unchangedCount = graph.nodes.length;
const update = { const update = {
addedNodes: [], addedNodes: [],
updatedNodes: [], updatedNodes: [],
@@ -571,7 +814,7 @@ describe("applyGraphUpdate", () => {
resolvedUnknownNodeIds: [], resolvedUnknownNodeIds: [],
affectedNodeIds: [], affectedNodeIds: [],
}; };
const result = applyGraphUpdate(graph, update); const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.nodes.length).toBe(unchangedCount); expect(result.nodes.length).toBe(unchangedCount);
@@ -580,21 +823,23 @@ describe("applyGraphUpdate", () => {
it("applies multiple operations in one update", () => { it("applies multiple operations in one update", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const newNode = makeNode({ id: "n-multi", label: "Multi" }); const newNode = makeNode({ id: "n-multi", label: "Multi" });
const update = { const update = {
addedNodes: [newNode], addedNodes: [newNode],
updatedNodes: [{ updatedNodes: [
nodeId: "n4", {
previousStatus: "unknown", nodeId: "n4",
newStatus: "resolved", previousStatus: "unknown",
reason: "Multiple ops test", newStatus: "resolved",
}], reason: "Multiple ops test",
},
],
addedEdges: [makeEdge({ fromNodeId: "n-multi", toNodeId: "n1" })], addedEdges: [makeEdge({ fromNodeId: "n-multi", toNodeId: "n1" })],
removedEdgeIds: [graph.edges[0]?.id || ""], removedEdgeIds: [graph.edges[0]?.id || ""],
resolvedUnknownNodeIds: ["n4"], resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [], affectedNodeIds: [],
}; };
const result = applyGraphUpdate(graph, update); const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true); expect(result.success).toBe(true);
}); });
@@ -604,7 +849,7 @@ describe("validateGraphUpdate", () => {
it("accepts a no-op update with added nodes", () => { it("accepts a no-op update with added nodes", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const newNode = makeNode({ id: "n-new", label: "New" }); const newNode = makeNode({ id: "n-new", label: "New" });
const result = validateGraphUpdate(graph, { const result = validateGraphUpdate(graph, {
addedNodes: [newNode], addedNodes: [newNode],
updatedNodes: [], updatedNodes: [],
@@ -613,37 +858,39 @@ describe("validateGraphUpdate", () => {
resolvedUnknownNodeIds: [], resolvedUnknownNodeIds: [],
affectedNodeIds: [], affectedNodeIds: [],
}); });
expect(result.valid).toBe(true); expect(result.valid).toBe(true);
}); });
it("rejects update with no meaningful change", () => { it("rejects update with no meaningful change", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const result = validateGraphUpdate(graph, { const result = validateGraphUpdate(graph, {
addedNodes: [], addedNodes: [],
updatedNodes: [{ updatedNodes: [
nodeId: "n1", {
previousStatus: null, nodeId: "n1",
newStatus: null, previousStatus: null,
previousValue: null, newStatus: null,
newValue: null, previousValue: null,
reason: "No change test", newValue: null,
}], reason: "No change test",
},
],
addedEdges: [], addedEdges: [],
removedEdgeIds: [], removedEdgeIds: [],
resolvedUnknownNodeIds: [], resolvedUnknownNodeIds: [],
affectedNodeIds: [], affectedNodeIds: [],
}); });
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes("no meaningful"))).toBe(true); expect(result.errors.some((e) => e.includes("no meaningful"))).toBe(true);
}); });
it("rejects duplicate node IDs in additions", () => { it("rejects duplicate node IDs in additions", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const existingNode = graph.nodes[0]; const existingNode = graph.nodes[0];
const result = validateGraphUpdate(graph, { const result = validateGraphUpdate(graph, {
addedNodes: [existingNode], // Duplicate ID addedNodes: [existingNode], // Duplicate ID
updatedNodes: [], updatedNodes: [],
@@ -652,54 +899,58 @@ describe("validateGraphUpdate", () => {
resolvedUnknownNodeIds: [], resolvedUnknownNodeIds: [],
affectedNodeIds: [], affectedNodeIds: [],
}); });
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
}); });
it("rejects update to non-existent node", () => { it("rejects update to non-existent node", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const result = validateGraphUpdate(graph, { const result = validateGraphUpdate(graph, {
addedNodes: [], addedNodes: [],
updatedNodes: [{ updatedNodes: [
nodeId: "ghost-node", {
previousStatus: null, nodeId: "ghost-node",
newStatus: "known", previousStatus: null,
reason: "test", newStatus: "known",
}], reason: "test",
},
],
addedEdges: [], addedEdges: [],
removedEdgeIds: [], removedEdgeIds: [],
resolvedUnknownNodeIds: [], resolvedUnknownNodeIds: [],
affectedNodeIds: [], affectedNodeIds: [],
}); });
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
}); });
it("accepts valid status change as meaningful", () => { it("accepts valid status change as meaningful", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const result = validateGraphUpdate(graph, { const result = validateGraphUpdate(graph, {
addedNodes: [], addedNodes: [],
updatedNodes: [{ updatedNodes: [
nodeId: "n4", {
previousStatus: "unknown", nodeId: "n4",
newStatus: "known", previousStatus: "unknown",
reason: "Confirmed", newStatus: "known",
}], reason: "Confirmed",
},
],
addedEdges: [], addedEdges: [],
removedEdgeIds: [], removedEdgeIds: [],
resolvedUnknownNodeIds: [], resolvedUnknownNodeIds: [],
affectedNodeIds: [], affectedNodeIds: [],
}); });
expect(result.valid).toBe(true); expect(result.valid).toBe(true);
}); });
it("rejects oversized update (>100KB)", () => { it("rejects oversized update (>100KB)", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const largeDescription = "x".repeat(150000); const largeDescription = "x".repeat(150000);
const result = validateGraphUpdate(graph, { const result = validateGraphUpdate(graph, {
addedNodes: [{ label: largeDescription }], // Will create huge JSON addedNodes: [{ label: largeDescription }], // Will create huge JSON
updatedNodes: [], updatedNodes: [],
@@ -708,14 +959,16 @@ describe("validateGraphUpdate", () => {
resolvedUnknownNodeIds: [], resolvedUnknownNodeIds: [],
affectedNodeIds: [], affectedNodeIds: [],
}); });
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
expect(result.errors.some(e => e.includes("100KB") || e.includes("exceeds"))).toBe(true); expect(
result.errors.some((e) => e.includes("100KB") || e.includes("exceeds")),
).toBe(true);
}); });
it("returns empty errors array for valid update", () => { it("returns empty errors array for valid update", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const result = validateGraphUpdate(graph, { const result = validateGraphUpdate(graph, {
addedNodes: [makeNode({ id: "n-valid", label: "Valid" })], addedNodes: [makeNode({ id: "n-valid", label: "Valid" })],
updatedNodes: [], updatedNodes: [],
@@ -724,7 +977,7 @@ describe("validateGraphUpdate", () => {
resolvedUnknownNodeIds: [], resolvedUnknownNodeIds: [],
affectedNodeIds: [], affectedNodeIds: [],
}); });
expect(result.valid).toBe(true); expect(result.valid).toBe(true);
expect(result.errors.length).toBe(0); expect(result.errors.length).toBe(0);
}); });
@@ -735,47 +988,55 @@ describe("validateGraphUpdate", () => {
describe("update lifecycle integration", () => { describe("update lifecycle integration", () => {
it("complete update cycle: validate → apply → verify", () => { it("complete update cycle: validate → apply → verify", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
// Create a meaningful update // Create a meaningful update
const newNode = makeNode({ id: "n-new", label: "New Discovery" }); const newNode = makeNode({ id: "n-new", label: "New Discovery" });
const newEdge = makeEdge({ fromNodeId: "n1", toNodeId: "n-new", relationship: "supports" }); const newEdge = makeEdge({
fromNodeId: "n1",
toNodeId: "n-new",
relationship: "supports",
});
// Validate first // Validate first
const validationResult = validateGraphUpdate(graph, { const validationResult = validateGraphUpdate(graph, {
addedNodes: [newNode], addedNodes: [newNode],
updatedNodes: [{ updatedNodes: [
nodeId: "n4", {
previousStatus: "unknown", nodeId: "n4",
newStatus: "resolved", previousStatus: "unknown",
reason: "Answered via follow-up question", newStatus: "resolved",
}], reason: "Answered via follow-up question",
},
],
addedEdges: [newEdge], addedEdges: [newEdge],
removedEdgeIds: [], removedEdgeIds: [],
resolvedUnknownNodeIds: ["n4"], resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [], affectedNodeIds: [],
}); });
expect(validationResult.valid).toBe(true); expect(validationResult.valid).toBe(true);
// Apply // Apply
const applyResult = applyGraphUpdate(graph, { const applyResult = applyGraphUpdate(graph, {
addedNodes: [newNode], addedNodes: [newNode],
updatedNodes: [{ updatedNodes: [
nodeId: "n4", {
previousStatus: "unknown", nodeId: "n4",
newStatus: "resolved", previousStatus: "unknown",
reason: "Answered via follow-up question", newStatus: "resolved",
}], reason: "Answered via follow-up question",
},
],
addedEdges: [newEdge], addedEdges: [newEdge],
removedEdgeIds: [], removedEdgeIds: [],
resolvedUnknownNodeIds: ["n4"], resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [], affectedNodeIds: [],
}); });
expect(applyResult.success).toBe(true); expect(applyResult.success).toBe(true);
expect(applyResult.nodes.length).toBe(graph.nodes.length + 1); expect(applyResult.nodes.length).toBe(graph.nodes.length + 1);
expect(applyResult.edges.length).toBe(graph.edges.length + 1); expect(applyResult.edges.length).toBe(graph.edges.length + 1);
expect(applyResult.resolvedNodeIds).toContain("n4"); expect(applyResult.resolvedNodeIds).toContain("n4");
// Verify post-apply integrity // Verify post-apply integrity
const postValidation = validateGraphReferences(applyResult); const postValidation = validateGraphReferences(applyResult);
expect(postValidation.valid).toBe(true); expect(postValidation.valid).toBe(true);
@@ -783,10 +1044,12 @@ describe("update lifecycle integration", () => {
it("reject and retry: invalid update should be caught", () => { it("reject and retry: invalid update should be caught", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const invalidUpdate = { const invalidUpdate = {
addedNodes: [], addedNodes: [],
updatedNodes: [{ nodeId: "ghost-node", newStatus: "known", reason: "test" }], updatedNodes: [
{ nodeId: "ghost-node", newStatus: "known", reason: "test" },
],
addedEdges: [], addedEdges: [],
removedEdgeIds: [], removedEdgeIds: [],
resolvedUnknownNodeIds: [], resolvedUnknownNodeIds: [],
@@ -795,7 +1058,7 @@ describe("update lifecycle integration", () => {
// Validation should catch it // Validation should catch it
expect(validateGraphUpdate(graph, invalidUpdate).valid).toBe(false); expect(validateGraphUpdate(graph, invalidUpdate).valid).toBe(false);
// Apply should also catch it // Apply should also catch it
expect(applyGraphUpdate(graph, invalidUpdate).success).toBe(false); expect(applyGraphUpdate(graph, invalidUpdate).success).toBe(false);
}); });
@@ -803,21 +1066,23 @@ describe("update lifecycle integration", () => {
it("preserve unchanged nodes during update", () => { it("preserve unchanged nodes during update", () => {
const graph = makeTestGraph(); const graph = makeTestGraph();
const originalNode1 = JSON.parse(JSON.stringify(graph.nodes[0])); const originalNode1 = JSON.parse(JSON.stringify(graph.nodes[0]));
applyGraphUpdate(graph, { applyGraphUpdate(graph, {
addedNodes: [], addedNodes: [],
updatedNodes: [{ updatedNodes: [
nodeId: "n4", {
previousStatus: "unknown", nodeId: "n4",
newStatus: "resolved", previousStatus: "unknown",
reason: "Test preserve", newStatus: "resolved",
}], reason: "Test preserve",
},
],
addedEdges: [], addedEdges: [],
removedEdgeIds: [], removedEdgeIds: [],
resolvedUnknownNodeIds: ["n4"], resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [], affectedNodeIds: [],
}); });
// Re-read the graph and check n1 wasn't modified // Re-read the graph and check n1 wasn't modified
expect(graph.nodes[0].id).toBe("n1"); expect(graph.nodes[0].id).toBe("n1");
expect(graph.nodes[0].status).toBe("unknown"); // unchanged expect(graph.nodes[0].status).toBe("unknown"); // unchanged