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 {
applyGraphUpdate,
buildDeterministicQuestionForUnknown,
detectDuplicateNodeIds,
findAffectedNodes,
scoreUnknownCandidate,
selectActiveUnknownCandidate,
validateGraphReferences,
validateGraphUpdate,
@@ -195,6 +197,20 @@ function validateSelectedQuestion(graph, proposal) {
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 };
}
@@ -571,22 +587,32 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
)?.nodeId ?? null;
}
if (
validatedProposal.selectedQuestion?.nodeId &&
newActiveUnknownNodeId !== validatedProposal.selectedQuestion.nodeId
) {
return {
success: false,
stage: "proposal_compatibility",
errors: [
`activeUnknownNodeId and selectedQuestion.nodeId disagree: "${newActiveUnknownNodeId}" vs "${validatedProposal.selectedQuestion.nodeId}"`,
],
};
const deterministicSelection = selectActiveUnknownCandidate(
updatedSituationGraph,
updatedSituationGraph.resolvedNodeIds,
);
if (deterministicSelection?.nodeId) {
newActiveUnknownNodeId = deterministicSelection.nodeId;
}
updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId;
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(
updatedSituationGraph,
);
@@ -636,7 +662,7 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds,
previousActiveUnknownNodeId,
newActiveUnknownNodeId,
selectedQuestion: validatedProposal.selectedQuestion,
selectedQuestion: finalSelectedQuestion,
changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds),
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.
14. Do not invent evidence.
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.
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.
20. Use empty arrays when there are no changes in a category.
21. Never return null array entries.
22. Never use unknown enum values.
23. Do not change existing IDs.
24. Do not replace the whole graph, and do not restate unchanged graph content inside the proposal.
19. Do not prioritise downstream implementation, pricing, optimisation, or speculative branches ahead of prerequisite definitions, actors, success criteria, constraints, measures, or terminology.
20. Return selectedQuestion as null only when no consequential unresolved unknown remains.
21. Use empty arrays when there are no changes in a category.
22. Never return null array entries.
23. Never use unknown enum values.
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
- 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 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.
- 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.
## Example Constraint Reminder
+267 -62
View File
@@ -5,26 +5,196 @@
* 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 ──
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`);
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`);
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`);
errors.push(
`Node "${node.id}" depends on "${dep}" which does not exist`,
);
}
}
for (const aff of node.affects) {
@@ -33,16 +203,20 @@ export function validateGraphReferences(graph) {
}
}
}
for (const edge of graph.edges) {
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)) {
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 };
}
@@ -76,24 +250,31 @@ export function detectDuplicateNodeIds(nodes) {
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 });
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 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)) {
@@ -101,13 +282,13 @@ export function findDependentNodes(graph, nodeId) {
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);
@@ -115,7 +296,7 @@ export function findDependentNodes(graph, nodeId) {
}
}
}
return [...affected];
}
@@ -124,10 +305,14 @@ export function findDependentNodes(graph, 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);
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 || []);
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) {
@@ -147,7 +332,11 @@ export function findAffectedNodes(graph, nodeId) {
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))) {
if (
node.id !== nodeId &&
!affected.has(node.id) &&
(node.dependsOn.includes(current) || node.affects.includes(current))
) {
affected.add(node.id);
queue.push(node.id);
}
@@ -164,10 +353,10 @@ export function resolveUnknownNode(graph, nodeId, newStatus, newValue, reason) {
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,
@@ -184,26 +373,37 @@ export function resolveUnknownNode(graph, nodeId, newStatus, newValue, reason) {
export function selectActiveUnknownCandidate(graph, resolvedNodeIds) {
// Skip already resolved nodes
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;
// Prioritise: critical unknowns first, then those that are depended upon most
const dependencyCount = unresolved.map((n) => {
const deps = findDependentNodes(graph, n.id).length;
const importanceOrder = { critical: 3, important: 2, supporting: 1, incidental: 0 };
const impScore = importanceOrder[n.confidence] || 0;
return { node: n, score: deps * 2 + impScore };
const scoredCandidates = unresolved.map((node) => ({
node,
...scoreUnknownCandidate(graph, node, resolvedNodeIds),
}));
scoredCandidates.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (b.downstreamCount !== a.downstreamCount) {
return b.downstreamCount - a.downstreamCount;
}
if (a.unresolvedParentUnknownCount !== b.unresolvedParentUnknownCount) {
return a.unresolvedParentUnknownCount - b.unresolvedParentUnknownCount;
}
return a.node.label.localeCompare(b.node.label);
});
dependencyCount.sort((a, b) => b.score - a.score);
// Return the highest-scoring unresolved unknown
const best = dependencyCount[0];
const best = scoredCandidates[0];
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 ──
@@ -211,7 +411,7 @@ export function selectActiveUnknownCandidate(graph, resolvedNodeIds) {
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) {
@@ -221,34 +421,38 @@ export function applyGraphUpdate(graph, update) {
}
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}"`);
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}"`);
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;
}
@@ -257,22 +461,22 @@ export function applyGraphUpdate(graph, update) {
}
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);
@@ -283,10 +487,12 @@ export function applyGraphUpdate(graph, update) {
toNode.dependsOn.push(newEdge.fromNodeId);
}
}
// Add resolved node IDs
const newResolved = [...new Set([...graph.resolvedNodeIds, ...update.resolvedUnknownNodeIds])];
const newResolved = [
...new Set([...graph.resolvedNodeIds, ...update.resolvedUnknownNodeIds]),
];
return {
success: true,
nodes: newNodes,
@@ -299,7 +505,7 @@ export function applyGraphUpdate(graph, update) {
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) {
@@ -309,7 +515,7 @@ export function validateGraphUpdate(graph, update) {
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) {
@@ -317,13 +523,13 @@ export function validateGraphUpdate(graph, update) {
errors.push(`Cannot update non-existent node: "${upd.nodeId}"`);
}
}
// Reject updates with no meaningful change
const statusChanged = update.updatedNodes.some(
(u) => u.previousStatus !== null && u.newStatus !== u.previousStatus
(u) => u.previousStatus !== null && u.newStatus !== u.previousStatus,
);
const valueChanged = update.updatedNodes.some(
(u) => u.previousValue !== null && u.newValue !== u.previousValue
(u) => u.previousValue !== null && u.newValue !== u.previousValue,
);
const hasMeaningfulChange =
@@ -336,13 +542,12 @@ export function validateGraphUpdate(graph, update) {
if (!hasMeaningfulChange) {
errors.push("Update contains no meaningful change");
}
// Reject oversized input
const totalSize = JSON.stringify(update).length;
if (totalSize > 100000) {
errors.push(`Proposed graph update exceeds 100KB (${totalSize} bytes)`);
}
return { valid: errors.length === 0, errors };
}