>
)}
diff --git a/components/situation-graph-view.jsx b/components/situation-graph-view.jsx
index 1ca8a25..d7bd050 100644
--- a/components/situation-graph-view.jsx
+++ b/components/situation-graph-view.jsx
@@ -8,6 +8,8 @@ function NodeBadge({ children, tone = "gray" }) {
blue: "border-blue-200 bg-blue-50 text-blue-700",
green: "border-green-200 bg-green-50 text-green-700",
yellow: "border-yellow-200 bg-yellow-50 text-yellow-700",
+ red: "border-red-200 bg-red-50 text-red-700",
+ purple: "border-purple-200 bg-purple-50 text-purple-700",
};
return (
@@ -17,7 +19,13 @@ function NodeBadge({ children, tone = "gray" }) {
);
}
-function NodeGroup({ title, nodes }) {
+function NodeGroup({
+ title,
+ nodes,
+ resolvedNodeIds = new Set(),
+ newlySurfacedNodeIds = new Set(),
+ activeUnknownNodeId = null,
+}) {
if (!nodes?.length) return null;
return (
@@ -32,6 +40,15 @@ function NodeGroup({ title, nodes }) {
+ )}
+ {newlySurfacedNodeIds.has(node.id) && (
+
+ )}
+ {activeUnknownNodeId === node.id && (
+
{node.value}
@@ -52,6 +69,7 @@ function NodeGroup({ title, nodes }) {
export default function SituationGraphView({
situationGraph,
selectedQuestion,
+ newlySurfacedNodeIds = [],
}) {
if (!situationGraph) return null;
@@ -70,6 +88,9 @@ export default function SituationGraphView({
return acc;
}, {});
+ const resolvedNodeIdSet = new Set(situationGraph.resolvedNodeIds || []);
+ const newlySurfacedNodeIdSet = new Set(newlySurfacedNodeIds || []);
+
return (
{selectedQuestionText && (
@@ -110,6 +131,9 @@ export default function SituationGraphView({
key={kind}
title={kind.replace(/_/g, " ")}
nodes={nodes}
+ resolvedNodeIds={resolvedNodeIdSet}
+ newlySurfacedNodeIds={newlySurfacedNodeIdSet}
+ activeUnknownNodeId={situationGraph.activeUnknownNodeId}
/>
))}
diff --git a/docs/v0.5-question-priority-generalisation.md b/docs/v0.5-question-priority-generalisation.md
new file mode 100644
index 0000000..9c4a999
--- /dev/null
+++ b/docs/v0.5-question-priority-generalisation.md
@@ -0,0 +1,48 @@
+# v0.5 Question Priority Generalisation
+
+## Hypothesis
+
+The current deterministic unknown selector and graph-context question formulator should generalise across several decision types by selecting a foundational unknown before downstream implementation or pricing leaves.
+
+## Scenarios
+
+1. Should we hire another engineer?
+2. Should we replace the delivery vans?
+3. Should we launch in another country?
+4. Should we continue a project that is over budget?
+5. Should we introduce a paid support tier?
+
+## Results
+
+| Scenario | Selected unknown | Strategy | Pass/Fail |
+| ---------------------------- | --------------------------- | -------------------- | --------- |
+| Hire another engineer | `hire-success-criteria` | `decision criterion` | Pass |
+| Replace the delivery vans | `van-reliability-threshold` | `decision criterion` | Pass |
+| Launch in another country | `country-value-threshold` | `actor/customer` | Pass |
+| Continue over-budget project | `project-benefit-threshold` | `decision criterion` | Pass |
+| Introduce paid support tier | `support-value-threshold` | `actor/customer` | Pass |
+
+## Repeated failure patterns
+
+Two repeated structural formulation failures appeared before the final pass:
+
+1. **Constraint language in surrounding graph context outranked node-local decision-threshold language** in more than one case.
+2. **Baseline language in surrounding graph context outranked node-local threshold language** in more than one case.
+
+Both failures affected formulation strategy, not deterministic unknown selection.
+
+## Code change made
+
+A small deterministic change was made in `lib/graph/question-formulator.js`:
+
+- prefer node-local `definition` language before broader criterion inference
+- prefer node-local `decision criterion` language before context-only `constraint` inference
+- only treat `baseline` or `constraint` as primary when the selected node itself carries that language, otherwise allow them as fallback strategies later
+
+No architecture, UI, persistence, prompt, scoring, additional model turns, or provider calls were added.
+
+## Remaining limitations
+
+- In two passing cases, the selector chose a threshold-style foundational node while the formulator still used an `actor/customer` strategy because related context strongly referenced customers or recipients.
+- This experiment is fixture-driven and deterministic; it is useful for regression protection, not scientific validation.
+- The suite exercises the production path without model calls, but it does not prove behaviour over arbitrary real-world graph structures.
diff --git a/docs/v0.5-release-notes.md b/docs/v0.5-release-notes.md
new file mode 100644
index 0000000..a271475
--- /dev/null
+++ b/docs/v0.5-release-notes.md
@@ -0,0 +1,58 @@
+# v0.5 Release Notes
+
+## Purpose of v0.5
+
+v0.5 stabilises the graph-backed one-turn update flow so the engine can resolve an answered unknown, surface consequential new unknowns, prioritise the next unknown deterministically, and formulate a deterministic follow-up question without changing the UI or adding more model turns.
+
+## Capabilities proven
+
+v0.5 includes:
+
+- resolving an existing unknown
+- surfacing consequential new unknowns
+- limiting emergent unknowns
+- deterministic information-value prioritisation
+- deterministic question formulation
+- generalisation across five decision types
+- graph-backed one-turn UI update
+
+## Five-case generalisation result
+
+All five deterministic fixture scenarios passed:
+
+1. Should we hire another engineer?
+2. Should we replace the delivery vans?
+3. Should we launch in another country?
+4. Should we continue a project that is over budget?
+5. Should we introduce a paid support tier?
+
+The selector chose a foundational unknown first in each case, avoided the downstream leaf first, required no model call, and preserved graph immutability during question formulation.
+
+## Key deterministic safeguards
+
+- proposal application re-selects the active unknown deterministically after validation
+- information-value scoring penalises downstream or prerequisite-blocked unknowns
+- emergent unknown validation limits additions and requires explicit answer-derived linkage
+- final question wording is reformulated from graph context without an extra model turn
+- question validation rejects compound, awkward, or pricing-led fallback phrasing
+
+## Known limitation
+
+A correctly selected threshold node can still be phrased using an actor/customer strategy when surrounding graph context strongly references customers or value recipients.
+
+This limitation is recorded for the next experiment and is not being fixed in the v0.5 release-prep task.
+
+## Deliberately excluded work
+
+- no reasoning-logic expansion beyond the small deterministic formulation fixes already landed on the branch
+- no new features
+- no UI changes
+- no persistence
+- no additional model turn
+- no Ollama calls for validation
+- no evaluator-suite runs
+- no Playwright runs
+
+## Next experimental question
+
+Can the question formulation strategy remain aligned with the selected node's role when surrounding graph context contains competing signals?
diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js
index 0410fae..da727f8 100644
--- a/lib/graph/apply-proposal.js
+++ b/lib/graph/apply-proposal.js
@@ -1,9 +1,11 @@
import { describeGraph } from "./builder.js";
+import { formulateQuestion } from "./question-formulator.js";
import { graphUpdateSchema, situationGraphSchema } from "./schema.js";
import {
applyGraphUpdate,
detectDuplicateNodeIds,
findAffectedNodes,
+ scoreUnknownCandidate,
selectActiveUnknownCandidate,
validateGraphReferences,
validateGraphUpdate,
@@ -41,6 +43,225 @@ function normaliseText(value) {
.trim();
}
+function buildNodeById(graph, addedNodes = []) {
+ return new Map(
+ [...graph.nodes, ...addedNodes].map((node) => [node.id, node]),
+ );
+}
+
+function isCompoundQuestion(question) {
+ if (typeof question !== "string") return false;
+ const trimmed = question.trim();
+ if (!trimmed) return false;
+
+ const questionMarks = (trimmed.match(/\?/g) || []).length;
+ if (questionMarks > 1) return true;
+ if (/\?\s*(and|or)\b/i.test(trimmed)) return true;
+ if (/\b(and|or)\b[^?]{0,60}\?/i.test(trimmed) && /,/.test(trimmed))
+ return true;
+ return false;
+}
+
+function validateAddedUnknowns(graph, proposal) {
+ const errors = [];
+ const addedUnknowns = proposal.addedNodes.filter(
+ (node) => node.kind === "unknown",
+ );
+
+ if (addedUnknowns.length > 3) {
+ errors.push(
+ `Proposal adds too many unknown nodes: ${addedUnknowns.length} (maximum 3)`,
+ );
+ }
+
+ const unresolvedExistingUnknowns = graph.nodes.filter(
+ (node) =>
+ node.kind === "unknown" &&
+ !proposal.resolvedUnknownNodeIds.includes(node.id),
+ );
+ const seenAddedUnknownMeanings = new Map();
+ const answerDerivedNodeIds = new Set([
+ ...proposal.updatedNodes.map((update) => update.nodeId),
+ ...proposal.resolvedUnknownNodeIds,
+ ...proposal.addedNodes
+ .filter((node) => node.kind !== "unknown")
+ .map((node) => node.id),
+ ]);
+ const proposalNodeById = buildNodeById(graph, proposal.addedNodes);
+
+ function hasExplicitNodeReference(fromNode, toNodeId) {
+ if (!fromNode || !toNodeId) return false;
+
+ return (
+ fromNode.parentId === toNodeId ||
+ fromNode.dependsOn.includes(toNodeId) ||
+ fromNode.affects.includes(toNodeId) ||
+ fromNode.childIds.includes(toNodeId)
+ );
+ }
+
+ function hasExplicitAnswerDerivedRelationship(unknownNode) {
+ const connectedEdge = proposal.addedEdges.find(
+ (edge) =>
+ (edge.fromNodeId === unknownNode.id &&
+ answerDerivedNodeIds.has(edge.toNodeId)) ||
+ (edge.toNodeId === unknownNode.id &&
+ answerDerivedNodeIds.has(edge.fromNodeId)),
+ );
+
+ if (connectedEdge) {
+ return true;
+ }
+
+ for (const answerDerivedNodeId of answerDerivedNodeIds) {
+ const answerDerivedNode = proposalNodeById.get(answerDerivedNodeId);
+
+ if (
+ hasExplicitNodeReference(unknownNode, answerDerivedNodeId) ||
+ hasExplicitNodeReference(answerDerivedNode, unknownNode.id)
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ for (const unknownNode of addedUnknowns) {
+ const meaningKeys = [
+ normaliseText(unknownNode.label),
+ normaliseText(unknownNode.description),
+ ].filter(Boolean);
+
+ for (const meaningKey of meaningKeys) {
+ if (seenAddedUnknownMeanings.has(meaningKey)) {
+ errors.push(
+ `Proposal adds duplicate unknown meaning: "${unknownNode.label}"`,
+ );
+ break;
+ }
+ seenAddedUnknownMeanings.set(meaningKey, unknownNode.id);
+ }
+
+ for (const existingUnknown of unresolvedExistingUnknowns) {
+ const existingMeaningKeys = [
+ normaliseText(existingUnknown.label),
+ normaliseText(existingUnknown.description),
+ ].filter(Boolean);
+ if (meaningKeys.some((key) => existingMeaningKeys.includes(key))) {
+ errors.push(
+ `Proposal adds a node duplicating unresolved unknown: "${existingUnknown.id}"`,
+ );
+ break;
+ }
+ }
+
+ if (
+ unknownNode.description.trim() === unknownNode.label.trim() ||
+ !/\b(because|matters|important|needed|relevant|so that|to determine|to decide)\b/i.test(
+ unknownNode.description,
+ )
+ ) {
+ errors.push(
+ `New unknown must include why it matters in its description: "${unknownNode.id}"`,
+ );
+ }
+
+ if (!hasExplicitAnswerDerivedRelationship(unknownNode)) {
+ errors.push(
+ `New unknown must be explicitly related to an answer-derived node: "${unknownNode.id}"`,
+ );
+ }
+ }
+
+ return errors;
+}
+
+function validateSelectedQuestion(graph, proposal) {
+ const errors = [];
+ const selectedQuestion = proposal.selectedQuestion;
+ const nodeById = buildNodeById(graph, proposal.addedNodes);
+
+ if (selectedQuestion == null) {
+ return { errors, selectedQuestionNodeId: null };
+ }
+
+ const node = nodeById.get(selectedQuestion.nodeId);
+ if (!node) {
+ errors.push(
+ `selectedQuestion references missing node: "${selectedQuestion.nodeId}"`,
+ );
+ return { errors, selectedQuestionNodeId: selectedQuestion.nodeId };
+ }
+
+ if (node.kind !== "unknown") {
+ errors.push(
+ `selectedQuestion must reference an unknown node: "${selectedQuestion.nodeId}"`,
+ );
+ }
+
+ const resolvesNode = proposal.resolvedUnknownNodeIds.includes(
+ selectedQuestion.nodeId,
+ );
+ const updatedStatus = proposal.updatedNodes.find(
+ (update) => update.nodeId === selectedQuestion.nodeId,
+ )?.newStatus;
+ const effectiveStatus = updatedStatus ?? node.status;
+
+ if (resolvesNode || effectiveStatus === "resolved") {
+ errors.push(
+ `selectedQuestion must reference an unresolved node: "${selectedQuestion.nodeId}"`,
+ );
+ }
+
+ if (
+ graph.activeUnknownNodeId &&
+ proposal.resolvedUnknownNodeIds.includes(graph.activeUnknownNodeId) &&
+ selectedQuestion.nodeId === graph.activeUnknownNodeId
+ ) {
+ errors.push(
+ `selectedQuestion cannot reselect the previous resolved unknown: "${selectedQuestion.nodeId}"`,
+ );
+ }
+
+ if (isCompoundQuestion(selectedQuestion.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 };
+}
+
+function validateQuestionSelectionRequirement(graph, proposal) {
+ const addedConsequentialUnknowns = proposal.addedNodes.filter(
+ (node) => node.kind === "unknown" && node.status !== "resolved",
+ );
+
+ if (
+ proposal.selectedQuestion == null &&
+ addedConsequentialUnknowns.length > 0
+ ) {
+ return [
+ "selectedQuestion is required when consequential unresolved unknowns remain after resolving the answered unknown",
+ ];
+ }
+
+ return [];
+}
+
function buildResolvedUnknownUpdate(node) {
return {
nodeId: node.id,
@@ -191,6 +412,9 @@ function buildAffectedNodeIds(graph, proposal) {
function buildChangesApplied(proposal, affectedNodeIds) {
return {
addedNodeCount: proposal.addedNodes.length,
+ addedUnknownCount: proposal.addedNodes.filter(
+ (node) => node.kind === "unknown",
+ ).length,
updatedNodeCount: proposal.updatedNodes.length,
addedEdgeCount: proposal.addedEdges.length,
removedEdgeCount: proposal.removedEdgeIds.length,
@@ -322,6 +546,18 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
proposalCompatibilityErrors.push(
...validateSemanticDuplicateUnknowns(situationGraph, validatedProposal),
);
+ proposalCompatibilityErrors.push(
+ ...validateAddedUnknowns(situationGraph, validatedProposal),
+ );
+
+ const selectedQuestionValidation = validateSelectedQuestion(
+ situationGraph,
+ validatedProposal,
+ );
+ proposalCompatibilityErrors.push(...selectedQuestionValidation.errors);
+ proposalCompatibilityErrors.push(
+ ...validateQuestionSelectionRequirement(situationGraph, validatedProposal),
+ );
if (proposalCompatibilityErrors.length > 0) {
return {
@@ -361,6 +597,10 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
newActiveUnknownNodeId = null;
}
+ if (validatedProposal.selectedQuestion?.nodeId) {
+ newActiveUnknownNodeId = validatedProposal.selectedQuestion.nodeId;
+ }
+
const remainingUnknownExists =
newActiveUnknownNodeId != null &&
updatedSituationGraph.nodes.some(
@@ -378,9 +618,47 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
)?.nodeId ?? null;
}
+ const deterministicSelection = selectActiveUnknownCandidate(
+ updatedSituationGraph,
+ updatedSituationGraph.resolvedNodeIds,
+ );
+
+ if (deterministicSelection?.nodeId) {
+ newActiveUnknownNodeId = deterministicSelection.nodeId;
+ }
+
updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId;
updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph);
+ const selectedNode = deterministicSelection?.nodeId
+ ? updatedSituationGraph.nodes.find(
+ (node) => node.id === deterministicSelection.nodeId,
+ )
+ : null;
+ const formulatedQuestion = selectedNode
+ ? formulateQuestion({
+ node: selectedNode,
+ graph: updatedSituationGraph,
+ context: {
+ resolvedValues: validatedProposal.updatedNodes
+ .map((update) => update.newValue)
+ .filter(
+ (value) => typeof value === "string" && value.trim().length > 0,
+ ),
+ },
+ })
+ : null;
+
+ const finalSelectedQuestion = deterministicSelection
+ ? {
+ nodeId: deterministicSelection.nodeId,
+ question:
+ formulatedQuestion?.question || deterministicSelection.question,
+ reason: formulatedQuestion?.reason || deterministicSelection.reason,
+ strategy: formulatedQuestion?.strategy,
+ }
+ : null;
+
const resultGraphValidation = situationGraphSchema.safeParse(
updatedSituationGraph,
);
@@ -430,6 +708,7 @@ export function applyValidatedProposal({ situationGraph, proposal }) {
resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds,
previousActiveUnknownNodeId,
newActiveUnknownNodeId,
+ selectedQuestion: finalSelectedQuestion,
changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds),
graphReferenceValidation: resultReferenceValidation,
};
diff --git a/lib/graph/orchestrator.js b/lib/graph/orchestrator.js
index 6fb3e01..fc97ac8 100644
--- a/lib/graph/orchestrator.js
+++ b/lib/graph/orchestrator.js
@@ -299,6 +299,7 @@ async function updateCaseWithDependencies(body, dependencies = {}) {
stage: "update_applied",
updatedSituationGraph: applicationResult.updatedSituationGraph,
proposal: applicationResult.graphUpdate,
+ selectedQuestion: applicationResult.selectedQuestion,
affectedNodeIds: applicationResult.affectedNodeIds,
resolvedUnknownNodeIds: applicationResult.resolvedUnknownNodeIds,
previousActiveUnknownNodeId:
diff --git a/lib/graph/prompt-builder.js b/lib/graph/prompt-builder.js
index 5aaf90b..1212567 100644
--- a/lib/graph/prompt-builder.js
+++ b/lib/graph/prompt-builder.js
@@ -68,6 +68,7 @@ The JSON object must contain exactly these top-level fields:
- removedEdgeIds
- resolvedUnknownNodeIds
- affectedNodeIds
+- selectedQuestion
## Required Shapes
- addedNodes: array of nodes using these exact keys:
@@ -79,27 +80,45 @@ The JSON object must contain exactly these top-level fields:
- removedEdgeIds: array of strings
- resolvedUnknownNodeIds: array of strings
- affectedNodeIds: array of strings
+- selectedQuestion: either null or an object using these exact keys:
+ nodeId, question, reason
## Proposal Rules
1. Propose changes only. Never return a replacement graph.
2. Preserve unrelated nodes and edges by omitting them from the proposal.
3. Reference existing node IDs when updating an existing concept.
4. Use addedNodes only for genuinely new concepts.
-5. Resolve the active unknown when the answer supports it.
-6. Propagate only through explicit dependencies or relationships already present in the graph.
-7. Do not invent evidence.
-8. Do not create unsupported causal edges.
-9. Do not ask more than one next question. In this contract you are not returning any next-question field at all.
-10. Use empty arrays when there are no changes in a category.
-11. Never return null array entries.
-12. Never use unknown enum values.
-13. Do not change existing IDs.
-14. Do not replace the whole graph, and do not restate unchanged graph content inside the proposal.
+5. Resolve the answered unknown first when the answer supports it.
+6. Then inspect the answer for newly introduced consequential uncertainty.
+7. Add new unknown nodes only when the answer introduces a new decision, claim, object, measure, dependency, or unresolved term directly relevant to the case.
+8. Add at most 3 new unknown nodes.
+9. Every new unknown must be directly traceable to the user's answer and its description must state why that uncertainty matters.
+9a. In the description of every new unknown, explicitly include a short why-it-matters clause using wording such as because, so that, needed to decide, or matters because.
+10. Do not add broad generic discovery questions.
+11. Do not add duplicate unknowns.
+12. Do not expand unrelated branches.
+13. Propagate only through explicit dependencies or relationships already present in the graph, except for the minimal new edges needed to connect validated new unknowns to the relevant answer-derived decision or context node.
+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. 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. 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.
- When an answer resolves an existing unknown, include that existing node ID in resolvedUnknownNodeIds and update that node rather than creating only a parallel observation.
-- If a new metric or observation is necessary, add the smallest set of nodes and edges needed.
+- 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
@@ -108,7 +127,7 @@ ${formatExampleAnswerBlock()}
## Output Contract Reminder
Return one JSON object only, with exact field names and exact enum values.
Never include a full graph.
-Never include a nextQuestion field.
+Never include any field other than the contract fields above.
`;
}
diff --git a/lib/graph/question-formulator.js b/lib/graph/question-formulator.js
new file mode 100644
index 0000000..da164ae
--- /dev/null
+++ b/lib/graph/question-formulator.js
@@ -0,0 +1,367 @@
+function normaliseText(value) {
+ return String(value || "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, " ")
+ .trim();
+}
+
+function sentenceCase(value) {
+ const trimmed = String(value || "").trim();
+ if (!trimmed) return "this uncertainty";
+ return trimmed.charAt(0).toLowerCase() + trimmed.slice(1);
+}
+
+function buildNodeMap(graph) {
+ return new Map((graph?.nodes || []).map((node) => [node.id, node]));
+}
+
+function collectRelatedNodes(node, graph) {
+ if (!node || !graph) return [];
+
+ const nodesById = buildNodeMap(graph);
+ const relatedIds = new Set([
+ ...(node.dependsOn || []),
+ ...(node.affects || []),
+ ...(node.childIds || []),
+ ]);
+
+ if (node.parentId) {
+ relatedIds.add(node.parentId);
+ }
+
+ for (const edge of graph.edges || []) {
+ if (edge.fromNodeId === node.id) {
+ relatedIds.add(edge.toNodeId);
+ }
+ if (edge.toNodeId === node.id) {
+ relatedIds.add(edge.fromNodeId);
+ }
+ }
+
+ return [...relatedIds].map((nodeId) => nodesById.get(nodeId)).filter(Boolean);
+}
+
+function collectResolvedContextValues(graph) {
+ const resolvedSet = new Set(graph?.resolvedNodeIds || []);
+
+ return (graph?.nodes || [])
+ .filter((node) => resolvedSet.has(node.id))
+ .map((node) => node.value)
+ .filter((value) => typeof value === "string" && value.trim().length > 0);
+}
+
+function extractMeaning(node) {
+ const raw = `${node?.label || ""} ${node?.description || ""}`.trim();
+ let meaning = String(
+ node?.label || node?.description || "this uncertainty",
+ ).trim();
+
+ const lowered = normaliseText(raw);
+ if (
+ /\b(customer|user|buyer|stakeholder|recipient|audience)\b/.test(lowered)
+ ) {
+ return "the relevant customer, user, or value recipient";
+ }
+
+ meaning = meaning
+ .replace(/^uncertainty regarding\s+/i, "")
+ .replace(/^uncertainty about\s+/i, "")
+ .replace(/^lack of\s+/i, "")
+ .replace(/^unknown\s+/i, "")
+ .replace(/^whether\s+/i, "")
+ .replace(/^the\s+/, "")
+ .trim();
+
+ if (!meaning) {
+ return "this uncertainty";
+ }
+
+ return sentenceCase(meaning);
+}
+
+function extractActionPhrase(texts) {
+ for (const text of texts) {
+ const value = String(text || "").trim();
+ if (!value) continue;
+
+ const matches = [
+ value.match(/\b(?:whether|deciding|decision) to\s+([^.,;:]+)/i),
+ value.match(/\b(?:justify|continuing|proceeding with)\s+([^.,;:]+)/i),
+ value.match(
+ /\b(build|launch|adopt|buy|continue|proceed|invest in|fund)\s+([^.,;:]+)/i,
+ ),
+ ].filter(Boolean);
+
+ const match = matches[0];
+ if (!match) continue;
+
+ const phrase = (match[1] || `${match[1] || ""} ${match[2] || ""}`)
+ .replace(/^to\s+/i, "")
+ .trim();
+
+ if (phrase) {
+ return phrase;
+ }
+ }
+
+ return null;
+}
+
+function toGerundPhrase(phrase) {
+ const trimmed = String(phrase || "").trim();
+ if (!trimmed) return "proceeding with this decision";
+
+ const [firstWord, ...rest] = trimmed.split(/\s+/);
+ const lower = firstWord.toLowerCase();
+ const irregular = {
+ be: "being",
+ build: "building",
+ continue: "continuing",
+ decide: "deciding",
+ proceed: "proceeding",
+ launch: "launching",
+ invest: "investing",
+ fund: "funding",
+ buy: "buying",
+ pay: "paying",
+ adopt: "adopting",
+ };
+
+ let gerund = irregular[lower];
+ if (!gerund) {
+ if (lower.endsWith("e") && !lower.endsWith("ee")) {
+ gerund = `${lower.slice(0, -1)}ing`;
+ } else {
+ gerund = `${lower}ing`;
+ }
+ }
+
+ return [gerund, ...rest].join(" ");
+}
+
+function detectStrategy({ node, graph, relatedNodes, combinedText, meaning }) {
+ const text = normaliseText(combinedText);
+ const nodeText = normaliseText(
+ `${node?.label || ""} ${node?.description || ""}`,
+ );
+ const relatedText = normaliseText(
+ relatedNodes
+ .map((relatedNode) => `${relatedNode.label} ${relatedNode.description}`)
+ .join(" "),
+ );
+ const resolvedValues = collectResolvedContextValues(graph);
+ const actionPhrase = extractActionPhrase([
+ ...resolvedValues,
+ ...relatedNodes.map((relatedNode) => relatedNode.value),
+ ...relatedNodes.map((relatedNode) => relatedNode.label),
+ ...relatedNodes.map((relatedNode) => relatedNode.description),
+ graph?.centralStatement,
+ ]);
+
+ const decisionContext =
+ /\b(decision|whether to|build|launch|continue|proceed|invest|allocate)\b/.test(
+ `${text} ${relatedText} ${resolvedValues.join(" ")}`,
+ ) || Boolean(actionPhrase);
+
+ const hasConstraintLanguage =
+ /\b(constraint|limit|budget|deadline|requirement|regulation|capacity)\b/.test(
+ text,
+ );
+ const hasPrimaryConstraintLanguage =
+ /\b(constraint|limit|budget|deadline|requirement|regulation|capacity)\b/.test(
+ nodeText,
+ );
+
+ if (/\b(customer|user|buyer|stakeholder|recipient|audience)\b/.test(text)) {
+ return { strategy: "actor/customer", meaning, actionPhrase };
+ }
+
+ const hasBaselineLanguage =
+ /\b(before|previous|baseline|prior|comparable state)\b/.test(text);
+ const hasPrimaryBaselineLanguage =
+ /\b(before|previous|baseline|prior|comparable state)\b/.test(nodeText);
+
+ if (hasBaselineLanguage && hasPrimaryBaselineLanguage) {
+ return { strategy: "baseline", meaning, actionPhrase };
+ }
+
+ if (/\b(when|timing|timeline|duration|sequence|milestone)\b/.test(text)) {
+ return { strategy: "transition/timing", meaning, actionPhrase };
+ }
+
+ const hasDefinitionLanguage =
+ /\b(define|definition|meaning|term|terminology)\b/.test(text);
+ const hasPrimaryDefinitionLanguage =
+ /\b(define|definition|meaning|term|terminology)\b/.test(nodeText);
+ const hasCriteriaLanguage =
+ /\b(success criteria|success threshold|threshold|decision criteria|criterion|justify|sufficient)\b/.test(
+ nodeText,
+ );
+ const hasDecisionValueLanguage =
+ decisionContext &&
+ /\b(value|commercial value|commercial viability|viability|justify|sufficient|success|threshold|criterion)\b/.test(
+ text,
+ );
+ const hasMeasurementLanguage =
+ /\b(metric|measure|measurable|roi|revenue projection|benchmark)\b/.test(
+ text,
+ );
+
+ if (hasDecisionValueLanguage && hasMeasurementLanguage) {
+ return { strategy: "measurement", meaning, actionPhrase };
+ }
+
+ if (hasPrimaryDefinitionLanguage) {
+ return { strategy: "definition", meaning, actionPhrase };
+ }
+
+ if (hasDecisionValueLanguage || hasCriteriaLanguage) {
+ return { strategy: "decision criterion", meaning, actionPhrase };
+ }
+
+ if (hasConstraintLanguage && hasPrimaryConstraintLanguage) {
+ return { strategy: "constraint", meaning, actionPhrase };
+ }
+
+ if (hasDefinitionLanguage) {
+ return { strategy: "definition", meaning, actionPhrase };
+ }
+
+ if (hasBaselineLanguage) {
+ return { strategy: "baseline", meaning, actionPhrase };
+ }
+
+ if (hasConstraintLanguage) {
+ return { strategy: "constraint", meaning, actionPhrase };
+ }
+
+ if (/\b(evidence|proof|validate|validation|signal|demand)\b/.test(text)) {
+ return { strategy: "evidence", meaning, actionPhrase };
+ }
+
+ if (hasMeasurementLanguage) {
+ return { strategy: "measurement", meaning, actionPhrase };
+ }
+
+ if (
+ /\b(objective|goal|outcome|problem|job to be done|benefit)\b/.test(text)
+ ) {
+ return { strategy: "objective", meaning, actionPhrase };
+ }
+
+ if (
+ node?.kind === "reported_claim" ||
+ node?.kind === "conclusion" ||
+ /\b(claim|assertion|true|false)\b/.test(text)
+ ) {
+ return { strategy: "evidence", meaning, actionPhrase };
+ }
+
+ return { strategy: "generic clarification", meaning, actionPhrase };
+}
+
+function buildQuestion({ strategy, meaning, actionPhrase }) {
+ switch (strategy) {
+ case "decision criterion":
+ return actionPhrase
+ ? `What outcome would demonstrate enough value to justify ${toGerundPhrase(actionPhrase)}?`
+ : "What outcome would be sufficient to justify this decision?";
+ case "definition":
+ return `What does ${meaning} mean in this situation?`;
+ case "evidence":
+ return `What evidence would show whether ${meaning} is true?`;
+ case "baseline":
+ return `What was the comparable state before ${meaning}?`;
+ case "actor/customer":
+ return "Who experiences the problem or receives the value in this situation?";
+ case "objective":
+ return "What outcome is this decision or effort meant to achieve?";
+ case "constraint":
+ return "What constraint most limits the available options in this situation?";
+ case "measurement":
+ return `What measure would determine whether ${meaning} is sufficient?`;
+ case "transition/timing":
+ return `When does ${meaning} become relevant in the decision or change?`;
+ default:
+ return `What specific fact would resolve whether ${meaning} is true?`;
+ }
+}
+
+function isCompoundQuestion(question) {
+ const trimmed = String(question || "").trim();
+ const questionMarks = (trimmed.match(/\?/g) || []).length;
+
+ if (questionMarks !== 1) return true;
+ if (/\?\s*(and|or)\b/i.test(trimmed)) return true;
+ if (/\b(and|or)\b[^?]{0,80}\?/i.test(trimmed) && /,/.test(trimmed)) {
+ return true;
+ }
+
+ return false;
+}
+
+function validateFormulatedQuestion(question, meaning) {
+ const trimmed = String(question || "").trim();
+ const lower = trimmed.toLowerCase();
+ const meaningWords = normaliseText(meaning)
+ .split(" ")
+ .filter((word) => word.length > 3);
+ const overlappingWord = meaningWords.find((word) => lower.includes(word));
+
+ if (!trimmed) return false;
+ if ((trimmed.match(/\?/g) || []).length !== 1) return false;
+ if (isCompoundQuestion(trimmed)) return false;
+ if (/^what is\s+/i.test(trimmed)) return false;
+ if (/^how should uncertainty regarding\b/i.test(trimmed)) return false;
+ if (/^what would resolve uncertainty regarding\b/i.test(trimmed))
+ return false;
+ if (
+ /\bprice|pricing|price point\b/i.test(trimmed) &&
+ !/\bprice\b/i.test(meaning)
+ ) {
+ return false;
+ }
+ if (
+ !overlappingWord &&
+ !/\b(decision|evidence|constraint|customer|value|outcome)\b/i.test(trimmed)
+ ) {
+ return false;
+ }
+
+ return true;
+}
+
+export function formulateQuestion({ node, graph, context = {} }) {
+ const relatedNodes = collectRelatedNodes(node, graph);
+ const meaning = extractMeaning(node);
+ const combinedText = [
+ node?.label,
+ node?.description,
+ ...relatedNodes.map((relatedNode) => relatedNode.label),
+ ...relatedNodes.map((relatedNode) => relatedNode.description),
+ graph?.centralStatement,
+ ...(context.resolvedValues || []),
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ const detected = detectStrategy({
+ node,
+ graph,
+ relatedNodes,
+ combinedText,
+ meaning,
+ });
+
+ let question = buildQuestion(detected);
+
+ if (!validateFormulatedQuestion(question, meaning)) {
+ question = `What evidence would resolve whether ${meaning} is true?`;
+ }
+
+ return {
+ question,
+ reason: `Formulated from graph context using the ${detected.strategy} strategy.`,
+ strategy: detected.strategy,
+ };
+}
diff --git a/lib/graph/schema.js b/lib/graph/schema.js
index c125c98..eff3ef6 100644
--- a/lib/graph/schema.js
+++ b/lib/graph/schema.js
@@ -101,11 +101,22 @@ const graphUpdateNodeChangeSchema = z.object({
nodeId: z.string().min(1),
previousStatus: z.enum(Object.values(SituationStatus)).nullable().optional(),
newStatus: z.enum(Object.values(SituationStatus)).nullable().optional(),
- previousValue: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
+ previousValue: z
+ .union([z.string(), z.number(), z.null()])
+ .nullable()
+ .optional(),
newValue: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
reason: z.string().min(1),
});
+export const selectedQuestionSchema = z
+ .object({
+ nodeId: z.string().min(1),
+ question: z.string().min(1),
+ reason: z.string().min(1),
+ })
+ .strict();
+
export const graphUpdateSchema = z.object({
addedNodes: z.array(situationNodeSchema).default([]),
updatedNodes: z.array(graphUpdateNodeChangeSchema).default([]),
@@ -113,6 +124,7 @@ export const graphUpdateSchema = z.object({
removedEdgeIds: z.array(z.string()).default([]),
resolvedUnknownNodeIds: z.array(z.string()).default([]),
affectedNodeIds: z.array(z.string()).default([]),
+ selectedQuestion: selectedQuestionSchema.nullable().default(null),
});
/** @typedef {z.infer} GraphUpdate */
@@ -169,7 +181,9 @@ export function makeNode(opts) {
/** Create a minimal valid edge — used in tests and fixtures */
export function makeEdge(opts) {
return situationEdgeSchema.parse({
- id: opts.id || "e" + opts.fromNodeId.slice(0,3) + "-" + opts.toNodeId.slice(0,3),
+ id:
+ opts.id ||
+ "e" + opts.fromNodeId.slice(0, 3) + "-" + opts.toNodeId.slice(0, 3),
fromNodeId: opts.fromNodeId,
toNodeId: opts.toNodeId,
relationship: opts.relationship ?? "supports",
diff --git a/lib/graph/update-proposal.js b/lib/graph/update-proposal.js
index f44a581..8e462ea 100644
--- a/lib/graph/update-proposal.js
+++ b/lib/graph/update-proposal.js
@@ -9,6 +9,8 @@ const TOP_LEVEL_ARRAY_FIELDS = [
"affectedNodeIds",
];
+const TOP_LEVEL_NULLABLE_FIELDS = ["selectedQuestion"];
+
function cloneJsonSafe(value) {
if (value == null) return value;
return JSON.parse(JSON.stringify(value));
@@ -79,6 +81,22 @@ function fillMissingOptionalArrays(proposal, normalisationsApplied) {
return proposal;
}
+function fillMissingNullableFields(proposal, normalisationsApplied) {
+ if (!proposal || typeof proposal !== "object") return proposal;
+
+ for (const field of TOP_LEVEL_NULLABLE_FIELDS) {
+ if (!(field in proposal)) {
+ proposal[field] = null;
+ normalisationsApplied.push({
+ path: [field],
+ change: "Filled missing optional nullable field with null",
+ });
+ }
+ }
+
+ return proposal;
+}
+
export function parseGraphUpdateProposal(rawResponse) {
const raw = rawResponse;
let parsed;
@@ -111,6 +129,7 @@ export function parseGraphUpdateProposal(rawResponse) {
let normalised = removeNullArrayEntries(parsed, [], normalisationsApplied);
normalised = applyKnownEnumAliases(normalised, normalisationsApplied);
normalised = fillMissingOptionalArrays(normalised, normalisationsApplied);
+ normalised = fillMissingNullableFields(normalised, normalisationsApplied);
const parsedProposal = graphUpdateSchema.safeParse(normalised);
diff --git a/lib/graph/utils.js b/lib/graph/utils.js
index 2ee736d..c1fc6cc 100644
--- a/lib/graph/utils.js
+++ b/lib/graph/utils.js
@@ -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 };
}
-
diff --git a/scripts/reproduce-commercial-value-update.mjs b/scripts/reproduce-commercial-value-update.mjs
new file mode 100644
index 0000000..d789f05
--- /dev/null
+++ b/scripts/reproduce-commercial-value-update.mjs
@@ -0,0 +1,97 @@
+import { mkdir, writeFile } from "node:fs/promises";
+
+const BASE_URL =
+ process.env.CONFIDENCE_ENGINE_BASE_URL || "http://127.0.0.1:3000";
+const OUTPUT_DIR = "tests-results/commercial-value-update";
+
+const scenario = "I think therefore I am";
+const answer =
+ "Deciding whether to build the Confidence Engine due to uncertainty about its commercial value.";
+
+async function postJson(path, body) {
+ const response = await fetch(`${BASE_URL}${path}`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ },
+ body: JSON.stringify(body),
+ });
+
+ const json = await response.json();
+ return { status: response.status, json };
+}
+
+function printLine(label, value) {
+ const rendered = value === undefined ? null : value;
+ console.log(`${label}: ${JSON.stringify(rendered)}`);
+}
+
+async function main() {
+ await mkdir(OUTPUT_DIR, { recursive: true });
+
+ const startResult = await postJson("/api/cases/start", { scenario });
+ await writeFile(
+ `${OUTPUT_DIR}/start-response.json`,
+ JSON.stringify(startResult, null, 2),
+ );
+
+ const selectedQuestion = startResult.json?.selectedQuestion?.question || null;
+
+ let updateResult = {
+ status: null,
+ json: {
+ success: false,
+ stage: "request_construction",
+ errors: ["Missing selected question from start response"],
+ },
+ };
+
+ if (startResult.json?.success && selectedQuestion) {
+ updateResult = await postJson("/api/cases/update", {
+ situationGraph: startResult.json.situationGraph,
+ previousQuestion: selectedQuestion,
+ answer,
+ });
+ }
+
+ await writeFile(
+ `${OUTPUT_DIR}/update-response.json`,
+ JSON.stringify(updateResult, null, 2),
+ );
+
+ printLine("start success", startResult.json?.success ?? false);
+ printLine("update success", updateResult.json?.success ?? false);
+ printLine("update stage", updateResult.json?.stage ?? null);
+ printLine(
+ "proposal added nodes",
+ updateResult.json?.proposal?.addedNodes?.map((node) => node.id) ?? null,
+ );
+ printLine(
+ "proposal added edges",
+ updateResult.json?.proposal?.addedEdges?.map((edge) => ({
+ id: edge.id,
+ fromNodeId: edge.fromNodeId,
+ toNodeId: edge.toNodeId,
+ relationship: edge.relationship,
+ })) ?? null,
+ );
+ printLine(
+ "proposal resolved unknown IDs",
+ updateResult.json?.proposal?.resolvedUnknownNodeIds ??
+ updateResult.json?.resolvedUnknownNodeIds ??
+ null,
+ );
+ printLine(
+ "errors",
+ updateResult.json?.errors ??
+ updateResult.json?.proposalErrors ??
+ updateResult.json?.graphValidationErrors ??
+ updateResult.json?.validationErrors ??
+ null,
+ );
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+});
diff --git a/tests/app/api/cases-update-route.test.js b/tests/app/api/cases-update-route.test.js
index bcca0ad..07565b9 100644
--- a/tests/app/api/cases-update-route.test.js
+++ b/tests/app/api/cases-update-route.test.js
@@ -25,7 +25,9 @@ function makeSuccessResult() {
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n1"],
affectedNodeIds: ["n1"],
+ selectedQuestion: null,
},
+ selectedQuestion: null,
affectedNodeIds: ["n1"],
resolvedUnknownNodeIds: ["n1"],
previousActiveUnknownNodeId: "n0",
@@ -268,6 +270,7 @@ describe("app/api/cases/update route", () => {
resolvedUnknownNodeIds: success.resolvedUnknownNodeIds,
previousActiveUnknownNodeId: success.previousActiveUnknownNodeId,
newActiveUnknownNodeId: success.newActiveUnknownNodeId,
+ selectedQuestion: success.selectedQuestion,
changesApplied: success.changesApplied,
diagnostics: success.diagnostics,
});
diff --git a/tests/fixtures/question-priority-generalisation.js b/tests/fixtures/question-priority-generalisation.js
new file mode 100644
index 0000000..d3cb817
--- /dev/null
+++ b/tests/fixtures/question-priority-generalisation.js
@@ -0,0 +1,433 @@
+import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
+
+function makeScenarioGraph({
+ scenario,
+ decisionNode,
+ answeredContextUnknown,
+ foundationalUnknown,
+ consequentialUnknown,
+ downstreamLeaf,
+}) {
+ const nodes = [
+ decisionNode,
+ answeredContextUnknown,
+ foundationalUnknown,
+ consequentialUnknown,
+ downstreamLeaf,
+ ];
+
+ const edges = [
+ makeEdge({
+ id: `${decisionNode.id}-to-${foundationalUnknown.id}`,
+ fromNodeId: decisionNode.id,
+ toNodeId: foundationalUnknown.id,
+ relationship: "depends_on",
+ description: `${decisionNode.label} depends on ${foundationalUnknown.label}.`,
+ }),
+ makeEdge({
+ id: `${answeredContextUnknown.id}-to-${consequentialUnknown.id}`,
+ fromNodeId: answeredContextUnknown.id,
+ toNodeId: consequentialUnknown.id,
+ relationship: "depends_on",
+ description: `${consequentialUnknown.label} was surfaced from resolved context.`,
+ }),
+ makeEdge({
+ id: `${foundationalUnknown.id}-to-${consequentialUnknown.id}`,
+ fromNodeId: foundationalUnknown.id,
+ toNodeId: consequentialUnknown.id,
+ relationship: "depends_on",
+ description: `${consequentialUnknown.label} depends on ${foundationalUnknown.label}.`,
+ }),
+ makeEdge({
+ id: `${consequentialUnknown.id}-to-${downstreamLeaf.id}`,
+ fromNodeId: consequentialUnknown.id,
+ toNodeId: downstreamLeaf.id,
+ relationship: "depends_on",
+ description: `${downstreamLeaf.label} depends on ${consequentialUnknown.label}.`,
+ }),
+ ];
+
+ return makeGraph({
+ centralStatement: scenario,
+ nodes,
+ edges,
+ activeUnknownNodeId: answeredContextUnknown.id,
+ resolvedNodeIds: [],
+ currentSummary: "Generalisation fixture graph",
+ });
+}
+
+export const questionPriorityGeneralisationFixtures = [
+ {
+ key: "hire-engineer",
+ scenario: "Should we hire another engineer?",
+ decisionType: "resourcing decision",
+ acceptableFoundationalUnknownNodeIds: [
+ "hire-success-criteria",
+ "hire-bottleneck",
+ ],
+ prohibitedFirstTopics: ["salary", "job advert", "programming language"],
+ acceptableQuestionStrategies: ["decision criterion", "constraint"],
+ notes:
+ "The first question should establish whether more engineering capacity is justified before compensation or implementation details.",
+ graph: makeScenarioGraph({
+ scenario: "Should we hire another engineer?",
+ decisionNode: makeNode({
+ id: "hire-decision",
+ label: "Hiring another engineer decision",
+ description: "Decision about increasing engineering capacity.",
+ kind: "state",
+ status: "known",
+ confidence: "medium",
+ value: "Deciding whether to hire another engineer",
+ childIds: ["hire-success-criteria"],
+ }),
+ answeredContextUnknown: makeNode({
+ id: "hire-delays-known",
+ label: "Delivery delays established",
+ description:
+ "Need to confirm whether recent delivery delays are real because this context determines whether a capacity decision is even relevant.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ value:
+ "The roadmap is slipping because the current team cannot clear the queue.",
+ childIds: ["hire-bottleneck"],
+ }),
+ foundationalUnknown: makeNode({
+ id: "hire-success-criteria",
+ label: "Hiring success threshold",
+ description:
+ "Need the success threshold because the hiring decision depends on what improvement would justify adding headcount.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ parentId: "hire-decision",
+ childIds: ["hire-bottleneck"],
+ }),
+ consequentialUnknown: makeNode({
+ id: "hire-bottleneck",
+ label: "Primary delivery bottleneck",
+ description:
+ "Need the main bottleneck because the team must know whether another engineer would relieve the limiting constraint.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ dependsOn: ["hire-success-criteria"],
+ parentId: "hire-success-criteria",
+ childIds: ["hire-salary"],
+ }),
+ downstreamLeaf: makeNode({
+ id: "hire-salary",
+ label: "Engineer salary budget",
+ description:
+ "Need the salary range because compensation planning comes after the hiring case is established.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ dependsOn: ["hire-bottleneck"],
+ parentId: "hire-bottleneck",
+ }),
+ }),
+ },
+ {
+ key: "replace-vans",
+ scenario: "Should we replace the delivery vans?",
+ decisionType: "asset replacement decision",
+ acceptableFoundationalUnknownNodeIds: [
+ "van-reliability-threshold",
+ "van-service-constraint",
+ ],
+ prohibitedFirstTopics: [
+ "purchase price",
+ "paint colour",
+ "finance provider",
+ ],
+ acceptableQuestionStrategies: ["decision criterion", "constraint"],
+ notes:
+ "The first question should establish whether the fleet is failing a threshold that justifies replacement.",
+ graph: makeScenarioGraph({
+ scenario: "Should we replace the delivery vans?",
+ decisionNode: makeNode({
+ id: "van-decision",
+ label: "Replace delivery vans decision",
+ description: "Decision about replacing the current delivery fleet.",
+ kind: "state",
+ status: "known",
+ confidence: "medium",
+ value: "Deciding whether to replace the delivery vans",
+ childIds: ["van-reliability-threshold"],
+ }),
+ answeredContextUnknown: makeNode({
+ id: "van-breakdowns-known",
+ label: "Breakdown trend confirmed",
+ description:
+ "Need to confirm whether the recent rise in breakdowns is real because that context determines whether fleet replacement is relevant.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ value:
+ "Breakdowns and missed deliveries have increased over the last quarter.",
+ childIds: ["van-service-constraint"],
+ }),
+ foundationalUnknown: makeNode({
+ id: "van-reliability-threshold",
+ label: "Replacement justification threshold",
+ description:
+ "Need the threshold because the replacement decision depends on what level of reliability loss is enough to justify replacing the fleet.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ parentId: "van-decision",
+ childIds: ["van-service-constraint"],
+ }),
+ consequentialUnknown: makeNode({
+ id: "van-service-constraint",
+ label: "Operational service constraint",
+ description:
+ "Need the limiting service constraint because the team must know how vehicle unreliability is affecting deliveries before comparing purchasing options.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ dependsOn: ["van-reliability-threshold"],
+ parentId: "van-reliability-threshold",
+ childIds: ["van-price"],
+ }),
+ downstreamLeaf: makeNode({
+ id: "van-price",
+ label: "Exact replacement purchase price",
+ description:
+ "Need the exact purchase price because financing analysis comes after replacement is justified.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ dependsOn: ["van-service-constraint"],
+ parentId: "van-service-constraint",
+ }),
+ }),
+ },
+ {
+ key: "launch-country",
+ scenario: "Should we launch in another country?",
+ decisionType: "market expansion decision",
+ acceptableFoundationalUnknownNodeIds: [
+ "country-customer",
+ "country-value-threshold",
+ ],
+ prohibitedFirstTopics: [
+ "launch date",
+ "office location",
+ "advertising channel",
+ ],
+ acceptableQuestionStrategies: ["actor/customer", "decision criterion"],
+ notes:
+ "The first question should clarify the customer or value case for expansion before rollout logistics.",
+ graph: makeScenarioGraph({
+ scenario: "Should we launch in another country?",
+ decisionNode: makeNode({
+ id: "country-decision",
+ label: "Launch in another country decision",
+ description: "Decision about entering a new national market.",
+ kind: "state",
+ status: "known",
+ confidence: "medium",
+ value: "Deciding whether to launch in another country",
+ childIds: ["country-customer"],
+ }),
+ answeredContextUnknown: makeNode({
+ id: "country-interest-known",
+ label: "Inbound interest confirmed",
+ description:
+ "Need to confirm whether inbound interest from another country is real because that context determines whether expansion is relevant.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ value:
+ "Prospective customers from another country are asking for access.",
+ childIds: ["country-value-threshold"],
+ }),
+ foundationalUnknown: makeNode({
+ id: "country-customer",
+ label: "Relevant customer in the new country",
+ description:
+ "Need the relevant customer because the expansion decision depends on who experiences the problem or receives the value in that market.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ parentId: "country-decision",
+ childIds: ["country-value-threshold"],
+ }),
+ consequentialUnknown: makeNode({
+ id: "country-value-threshold",
+ label: "Expansion value threshold",
+ description:
+ "Need the value threshold because the team must know what evidence of demand or value would justify entering the new country.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ dependsOn: ["country-customer"],
+ parentId: "country-customer",
+ childIds: ["country-launch-date"],
+ }),
+ downstreamLeaf: makeNode({
+ id: "country-launch-date",
+ label: "Country launch date",
+ description:
+ "Need the launch date because rollout planning follows once the expansion case is established.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ dependsOn: ["country-value-threshold"],
+ parentId: "country-value-threshold",
+ }),
+ }),
+ },
+ {
+ key: "over-budget-project",
+ scenario: "Should we continue a project that is over budget?",
+ decisionType: "continuation decision",
+ acceptableFoundationalUnknownNodeIds: [
+ "project-benefit-threshold",
+ "project-remaining-benefit",
+ ],
+ prohibitedFirstTopics: ["sunk cost", "project logo", "final launch date"],
+ acceptableQuestionStrategies: ["decision criterion", "objective"],
+ notes:
+ "The first question should establish remaining value or success threshold before sunk-cost framing or launch timing.",
+ graph: makeScenarioGraph({
+ scenario: "Should we continue a project that is over budget?",
+ decisionNode: makeNode({
+ id: "project-decision",
+ label: "Continue over-budget project decision",
+ description:
+ "Decision about continuing a project that has exceeded budget.",
+ kind: "state",
+ status: "known",
+ confidence: "medium",
+ value: "Deciding whether to continue the over-budget project",
+ childIds: ["project-benefit-threshold"],
+ }),
+ answeredContextUnknown: makeNode({
+ id: "project-overrun-known",
+ label: "Budget overrun confirmed",
+ description:
+ "Need to confirm whether the project is materially over budget because that context determines whether a continuation decision is relevant.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ value: "The project has exceeded its approved budget by 35 percent.",
+ childIds: ["project-remaining-benefit"],
+ }),
+ foundationalUnknown: makeNode({
+ id: "project-benefit-threshold",
+ label: "Continuation success threshold",
+ description:
+ "Need the threshold because the continuation decision depends on what remaining benefit would still justify completing the project.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ parentId: "project-decision",
+ childIds: ["project-remaining-benefit"],
+ }),
+ consequentialUnknown: makeNode({
+ id: "project-remaining-benefit",
+ label: "Remaining project benefit",
+ description:
+ "Need the remaining benefit because the team must know what value is still achievable before deciding whether to continue.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ dependsOn: ["project-benefit-threshold"],
+ parentId: "project-benefit-threshold",
+ childIds: ["project-launch-date"],
+ }),
+ downstreamLeaf: makeNode({
+ id: "project-launch-date",
+ label: "Final launch date",
+ description:
+ "Need the final launch date because scheduling details only matter after remaining value is established.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ dependsOn: ["project-remaining-benefit"],
+ parentId: "project-remaining-benefit",
+ }),
+ }),
+ },
+ {
+ key: "paid-support-tier",
+ scenario: "Should we introduce a paid support tier?",
+ decisionType: "commercial packaging decision",
+ acceptableFoundationalUnknownNodeIds: [
+ "support-customer",
+ "support-value-threshold",
+ ],
+ prohibitedFirstTopics: [
+ "subscription price",
+ "payment provider",
+ "tier name",
+ ],
+ acceptableQuestionStrategies: ["actor/customer", "decision criterion"],
+ notes:
+ "The first question should establish who values paid support or what outcome would justify offering it before pricing details.",
+ graph: makeScenarioGraph({
+ scenario: "Should we introduce a paid support tier?",
+ decisionNode: makeNode({
+ id: "support-decision",
+ label: "Introduce paid support tier decision",
+ description: "Decision about adding a paid support offering.",
+ kind: "state",
+ status: "known",
+ confidence: "medium",
+ value: "Deciding whether to introduce a paid support tier",
+ childIds: ["support-customer"],
+ }),
+ answeredContextUnknown: makeNode({
+ id: "support-requests-known",
+ label: "Support request pattern confirmed",
+ description:
+ "Need to confirm whether repeated requests for faster support responses are real because that context determines whether a paid tier is relevant.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ value:
+ "Some users are asking for guaranteed response times and escalation help.",
+ childIds: ["support-value-threshold"],
+ }),
+ foundationalUnknown: makeNode({
+ id: "support-customer",
+ label: "Customer willing to pay for support",
+ description:
+ "Need the customer because the decision depends on who experiences enough support pain or receives enough value to pay for a support tier.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ parentId: "support-decision",
+ childIds: ["support-value-threshold"],
+ }),
+ consequentialUnknown: makeNode({
+ id: "support-value-threshold",
+ label: "Paid support value threshold",
+ description:
+ "Need the value threshold because the team must know what outcome would justify introducing paid support before setting packaging details.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ dependsOn: ["support-customer"],
+ parentId: "support-customer",
+ childIds: ["support-price"],
+ }),
+ downstreamLeaf: makeNode({
+ id: "support-price",
+ label: "Support subscription price",
+ description:
+ "Need the subscription price because pricing and payment setup come after the support value case is established.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ dependsOn: ["support-value-threshold"],
+ parentId: "support-value-threshold",
+ }),
+ }),
+ },
+];
diff --git a/tests/graph/apply-proposal.test.js b/tests/graph/apply-proposal.test.js
index 0f90be6..12bd5bc 100644
--- a/tests/graph/apply-proposal.test.js
+++ b/tests/graph/apply-proposal.test.js
@@ -99,6 +99,7 @@ function makeApplicationFixture() {
removedEdgeIds: [],
resolvedUnknownNodeIds: [complaintRateUnknown.id],
affectedNodeIds: [qualityDeterioration.id],
+ selectedQuestion: null,
};
return {
@@ -444,6 +445,7 @@ describe("applyValidatedProposal", () => {
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
+ selectedQuestion: null,
},
});
@@ -455,4 +457,544 @@ describe("applyValidatedProposal", () => {
expect.arrayContaining([expect.stringContaining("no meaningful change")]),
);
});
+
+ it("resolves one unknown and adds consequential unknowns with one selected question", () => {
+ const { graph, ids } = makeApplicationFixture();
+
+ const proposal = {
+ addedNodes: [
+ makeNode({
+ id: "n-commercial-value",
+ label: "Commercial value definition",
+ description:
+ "Need a concrete definition of commercial value because the decision depends on it.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ }),
+ makeNode({
+ id: "n-demand-evidence",
+ label: "Evidence of demand",
+ description:
+ "Need evidence of demand because it matters to the build decision.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ }),
+ makeNode({
+ id: "n-build-decision",
+ label: "Build Confidence Engine decision",
+ description: "Decision situation 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-build-demand-evidence",
+ fromNodeId: "n-build-decision",
+ toNodeId: "n-demand-evidence",
+ relationship: "depends_on",
+ confidence: "medium",
+ description: "The decision depends on evidence of demand.",
+ }),
+ ],
+ removedEdgeIds: [],
+ resolvedUnknownNodeIds: [ids.complaintRateUnknown],
+ affectedNodeIds: [],
+ selectedQuestion: {
+ nodeId: "n-commercial-value",
+ question: "How should commercial value be defined for this decision?",
+ reason:
+ "This is the most consequential unresolved unknown introduced by the answer.",
+ },
+ };
+
+ const result = applyValidatedProposal({ situationGraph: graph, proposal });
+
+ expect(result.success).toBe(true);
+ expect(result.updatedSituationGraph.resolvedNodeIds).toContain(
+ ids.complaintRateUnknown,
+ );
+ expect(
+ result.updatedSituationGraph.nodes.some(
+ (node) => node.id === "n-commercial-value",
+ ),
+ ).toBe(true);
+ expect(
+ result.updatedSituationGraph.nodes.some(
+ (node) => node.id === "n-demand-evidence",
+ ),
+ ).toBe(true);
+ expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
+ expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
+ expect(result.selectedQuestion?.question).toMatch(/\?$/);
+ expect(result.selectedQuestion?.question.length).toBeGreaterThan(20);
+ expect(result.selectedQuestion?.question.toLowerCase()).not.toContain(
+ "price",
+ );
+ expect(result.selectedQuestion?.question.toLowerCase()).not.toContain(
+ "how should uncertainty regarding",
+ );
+ });
+
+ it("rejects more than 3 added unknowns", () => {
+ const { graph, proposal, ids } = makeApplicationFixture();
+
+ const result = applyValidatedProposal({
+ situationGraph: graph,
+ proposal: {
+ ...proposal,
+ addedNodes: [1, 2, 3, 4].map((index) =>
+ makeNode({
+ id: `n-unknown-${index}`,
+ label: `Unknown ${index}`,
+ description: `Need unknown ${index} because it matters to the decision.`,
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ }),
+ ),
+ addedEdges: [1, 2, 3, 4].map((index) =>
+ makeEdge({
+ id: `e-unknown-${index}`,
+ fromNodeId: ids.complaintRateUnknown,
+ toNodeId: `n-unknown-${index}`,
+ relationship: "depends_on",
+ confidence: "medium",
+ description: `Links unknown ${index}`,
+ }),
+ ),
+ selectedQuestion: {
+ nodeId: "n-unknown-1",
+ question: "What is unknown 1?",
+ reason: "Follow-up required.",
+ },
+ },
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.errors.join(" ")).toContain("too many unknown nodes");
+ });
+
+ it("rejects unrelated added unknowns", () => {
+ const { graph, proposal } = makeApplicationFixture();
+
+ const result = applyValidatedProposal({
+ situationGraph: graph,
+ proposal: {
+ ...proposal,
+ addedNodes: [
+ makeNode({
+ id: "n-unrelated",
+ label: "Office rent",
+ description:
+ "Need office rent because it matters to a different branch.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "low",
+ }),
+ ],
+ selectedQuestion: {
+ nodeId: "n-unrelated",
+ question: "What is the office rent?",
+ reason: "Unrelated test.",
+ },
+ },
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.errors.join(" ")).toContain(
+ "explicitly related to an answer-derived node",
+ );
+ });
+
+ it("accepts a newly added unknown explicitly linked through answer-derived node fields", () => {
+ const { graph, ids } = makeApplicationFixture();
+
+ const proposal = {
+ addedNodes: [
+ makeNode({
+ id: "n-answer-context",
+ label: "Build Confidence Engine decision",
+ description: "Decision context introduced by the answer.",
+ kind: "state",
+ status: "supported",
+ confidence: "medium",
+ childIds: ["n-commercial-value"],
+ }),
+ 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",
+ dependsOn: ["n-answer-context"],
+ }),
+ ],
+ 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: [],
+ removedEdgeIds: [],
+ resolvedUnknownNodeIds: [ids.complaintRateUnknown],
+ affectedNodeIds: [],
+ selectedQuestion: {
+ nodeId: "n-commercial-value",
+ question: "How should commercial value be defined for this decision?",
+ reason: "A consequential unknown remains unresolved.",
+ },
+ };
+
+ const result = applyValidatedProposal({ situationGraph: graph, proposal });
+
+ expect(result.success).toBe(true);
+ expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
+ });
+
+ it("rejects a newly added unknown linked only to the original unresolved node when that node is not answer-derived", () => {
+ const { graph, ids } = makeApplicationFixture();
+
+ const result = applyValidatedProposal({
+ situationGraph: graph,
+ 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",
+ dependsOn: [ids.complaintRateUnknown],
+ }),
+ ],
+ updatedNodes: [],
+ addedEdges: [
+ makeEdge({
+ id: "e-legacy-unknown-commercial-value",
+ fromNodeId: ids.complaintRateUnknown,
+ toNodeId: "n-commercial-value",
+ relationship: "depends_on",
+ confidence: "medium",
+ description: "Links only to the original unresolved unknown.",
+ }),
+ ],
+ removedEdgeIds: [],
+ resolvedUnknownNodeIds: [],
+ affectedNodeIds: [],
+ selectedQuestion: {
+ nodeId: "n-commercial-value",
+ question: "How should commercial value be defined for this decision?",
+ reason: "A consequential unknown remains unresolved.",
+ },
+ },
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.errors.join(" ")).toContain(
+ "explicitly related to an answer-derived node",
+ );
+ });
+
+ it("rejects a floating emergent unknown with no explicit relationship", () => {
+ const { graph } = makeApplicationFixture();
+
+ const result = applyValidatedProposal({
+ situationGraph: graph,
+ proposal: {
+ addedNodes: [
+ makeNode({
+ id: "n-floating",
+ label: "Floating unknown",
+ description: "Need this because it matters to the decision.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ }),
+ ],
+ updatedNodes: [],
+ addedEdges: [],
+ removedEdgeIds: [],
+ resolvedUnknownNodeIds: [],
+ affectedNodeIds: [],
+ selectedQuestion: {
+ nodeId: "n-floating",
+ question: "What would resolve Floating unknown?",
+ reason: "Test case for floating unknown rejection.",
+ },
+ },
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.errors.join(" ")).toContain(
+ "explicitly related to an answer-derived node",
+ );
+ });
+
+ it("accepts the reported live-shaped commercial-value proposal when the linkage is explicit in node references", () => {
+ const { graph, ids } = makeApplicationFixture();
+
+ const proposal = {
+ addedNodes: [
+ makeNode({
+ id: "answer_context_build",
+ label: "Build Confidence Engine decision context",
+ description:
+ "The answer introduces a concrete decision about whether to build Confidence Engine.",
+ kind: "state",
+ status: "known",
+ confidence: "high",
+ dependsOn: [ids.complaintRateUnknown, "nu_commercial_val"],
+ childIds: ["nu_commercial_val"],
+ affects: ["nu_commercial_val"],
+ }),
+ makeNode({
+ id: "nu_commercial_val",
+ label: "Commercial viability assessment of Confidence Engine",
+ description:
+ "The commercial viability of Confidence Engine remains unknown because resolving it is needed to decide whether building it is justified.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ dependsOn: ["answer_context_build"],
+ childIds: ["answer_context_build"],
+ }),
+ ],
+ updatedNodes: [
+ {
+ nodeId: ids.complaintRateUnknown,
+ previousStatus: "unknown",
+ newStatus: "resolved",
+ previousValue: null,
+ newValue:
+ "Deciding whether to build the Confidence Engine due to uncertainty about its commercial value.",
+ reason: "The answer resolves the original context unknown.",
+ },
+ ],
+ addedEdges: [],
+ removedEdgeIds: [],
+ resolvedUnknownNodeIds: [ids.complaintRateUnknown],
+ affectedNodeIds: [
+ ids.complaintRateUnknown,
+ "answer_context_build",
+ "nu_commercial_val",
+ ],
+ selectedQuestion: {
+ nodeId: "nu_commercial_val",
+ question:
+ "How should commercial viability be defined for this decision?",
+ reason: "A foundational commercial-value unknown remains unresolved.",
+ },
+ };
+
+ const result = applyValidatedProposal({ situationGraph: graph, proposal });
+
+ expect(result.success).toBe(true);
+ expect(result.updatedSituationGraph.resolvedNodeIds).toContain(
+ ids.complaintRateUnknown,
+ );
+ expect(
+ result.updatedSituationGraph.nodes.some(
+ (node) => node.id === "nu_commercial_val",
+ ),
+ ).toBe(true);
+ });
+
+ it("rejects selected question referencing resolved node", () => {
+ const { graph, proposal, ids } = makeApplicationFixture();
+
+ const result = applyValidatedProposal({
+ situationGraph: graph,
+ proposal: {
+ ...proposal,
+ selectedQuestion: {
+ nodeId: ids.complaintRateUnknown,
+ question: "What is the complaint rate?",
+ reason: "Invalid reselection.",
+ },
+ },
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.errors.join(" ")).toContain(
+ "selectedQuestion must reference an unresolved node",
+ );
+ });
+
+ it("active unknown matches selected question node", () => {
+ const { graph, ids } = makeApplicationFixture();
+
+ const proposal = {
+ addedNodes: [
+ makeNode({
+ id: "n-success-threshold",
+ label: "Success threshold",
+ description:
+ "Need a success threshold because the decision depends on it.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ }),
+ 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 unknown.",
+ },
+ ],
+ addedEdges: [
+ makeEdge({
+ id: "e-build-success-threshold",
+ fromNodeId: "n-build-decision",
+ toNodeId: "n-success-threshold",
+ relationship: "depends_on",
+ confidence: "medium",
+ description: "The decision depends on a success threshold.",
+ }),
+ ],
+ removedEdgeIds: [],
+ resolvedUnknownNodeIds: [ids.complaintRateUnknown],
+ affectedNodeIds: [],
+ selectedQuestion: {
+ nodeId: "n-success-threshold",
+ question: "What success threshold would justify building it?",
+ reason: "One consequential unknown remains.",
+ },
+ };
+
+ const result = applyValidatedProposal({ situationGraph: graph, proposal });
+
+ expect(result.success).toBe(true);
+ 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",
+ );
+ });
});
diff --git a/tests/graph/orchestrator.test.js b/tests/graph/orchestrator.test.js
index 155ba44..3a61642 100644
--- a/tests/graph/orchestrator.test.js
+++ b/tests/graph/orchestrator.test.js
@@ -113,6 +113,7 @@ function makeProposal(overrides = {}) {
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n-unknown"],
affectedNodeIds: [],
+ selectedQuestion: null,
...overrides,
};
}
@@ -529,6 +530,147 @@ describe("lib/graph/orchestrator startCase", () => {
expect(result.proposal.nextQuestion).toBeUndefined();
});
+ it("returns selectedQuestion from applied update proposal", async () => {
+ const { updateCase } = await import("@/lib/graph/orchestrator.js");
+ const provider = {
+ generateReconstruction: vi.fn().mockResolvedValue(
+ makeProposal({
+ addedNodes: [
+ makeNode({
+ id: "n-build-decision",
+ label: "Build Confidence Engine decision",
+ description: "Decision introduced by the answer.",
+ kind: "state",
+ status: "supported",
+ confidence: "medium",
+ }),
+ makeNode({
+ id: "n-commercial-value",
+ label: "Commercial value definition",
+ description:
+ "Need a concrete definition because the decision depends on it.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ }),
+ ],
+ addedEdges: [
+ {
+ id: "e-build-commercial-value",
+ fromNodeId: "n-build-decision",
+ toNodeId: "n-commercial-value",
+ relationship: "depends_on",
+ confidence: "medium",
+ description:
+ "The decision depends on commercial value definition.",
+ },
+ ],
+ selectedQuestion: {
+ nodeId: "n-commercial-value",
+ question:
+ "How should commercial value be defined for this decision?",
+ reason: "Consequential unresolved uncertainty remains.",
+ },
+ }),
+ ),
+ };
+
+ const result = await updateCase(makeUpdateRequest(), {
+ provider,
+ config: MOCK_CONFIG,
+ applyProposal: true,
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.selectedQuestion?.nodeId).toBe("n-commercial-value");
+ expect(result.newActiveUnknownNodeId).toBe("n-commercial-value");
+ expect(result.selectedQuestion?.question).not.toBe(
+ "How should commercial value be defined for this decision?",
+ );
+ expect(result.selectedQuestion?.question.toLowerCase()).not.toContain(
+ "how should uncertainty regarding",
+ );
+ });
+
+ 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 () => {
const { updateCase } = await import("@/lib/graph/orchestrator.js");
const applyValidatedProposal = vi.fn();
diff --git a/tests/graph/prompt-builder.test.js b/tests/graph/prompt-builder.test.js
index 7ea795a..8a8f9f1 100644
--- a/tests/graph/prompt-builder.test.js
+++ b/tests/graph/prompt-builder.test.js
@@ -72,6 +72,7 @@ describe("buildGraphUpdatePrompt", () => {
expect(prompt).toContain("removedEdgeIds");
expect(prompt).toContain("resolvedUnknownNodeIds");
expect(prompt).toContain("affectedNodeIds");
+ expect(prompt).toContain("selectedQuestion");
});
it("lists enum values", () => {
@@ -98,4 +99,16 @@ describe("buildGraphUpdatePrompt", () => {
expect(prompt).toContain("Return JSON only");
expect(prompt).toContain("Return one JSON object only");
});
+
+ it("describes controlled emergent unknown rules", () => {
+ const prompt = buildGraphUpdatePrompt(makeContext());
+ expect(prompt).toContain("Add at most 3 new unknown nodes");
+ expect(prompt).toContain("Resolve the answered unknown first");
+ expect(prompt).toContain(
+ "selectedQuestion.question must be one narrow non-compound question",
+ );
+ expect(prompt).toContain(
+ "the engine will deterministically choose final priority after validation",
+ );
+ });
});
diff --git a/tests/graph/question-formulator.test.js b/tests/graph/question-formulator.test.js
new file mode 100644
index 0000000..eba2f8e
--- /dev/null
+++ b/tests/graph/question-formulator.test.js
@@ -0,0 +1,209 @@
+import { describe, expect, it } from "vitest";
+import { formulateQuestion } from "@/lib/graph/question-formulator.js";
+import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
+
+function makeGraphFor(node, extra = {}) {
+ return makeGraph({
+ centralStatement: extra.centralStatement || "Decision context",
+ nodes: [node, ...(extra.nodes || [])],
+ edges: extra.edges || [],
+ activeUnknownNodeId: node.id,
+ resolvedNodeIds: extra.resolvedNodeIds || [],
+ currentSummary: "Test summary",
+ });
+}
+
+describe("formulateQuestion", () => {
+ it("commercial viability plus build decision produces a decision-criterion question", () => {
+ const unknown = makeNode({
+ id: "n-commercial",
+ label: "Uncertainty regarding the commercial value of the product",
+ description:
+ "Commercial justification remains unclear because the decision depends on it.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ parentId: "n-decision",
+ });
+ const decision = makeNode({
+ id: "n-decision",
+ label: "Build decision",
+ description: "Decision introduced by the answer.",
+ kind: "state",
+ status: "known",
+ confidence: "medium",
+ childIds: [unknown.id],
+ value: "Deciding whether to build the product",
+ });
+ const graph = makeGraphFor(unknown, {
+ nodes: [decision],
+ resolvedNodeIds: [decision.id],
+ });
+
+ const result = formulateQuestion({ node: unknown, graph });
+
+ expect(result.strategy).toBe("decision criterion");
+ expect(result.question).toContain("What outcome");
+ expect(result.question.toLowerCase()).toContain("justify");
+ });
+
+ it("commercial viability does not produce a pricing-first question", () => {
+ const unknown = makeNode({
+ id: "n-commercial",
+ label: "Commercial viability",
+ description:
+ "Commercial viability remains unresolved because the decision depends on it.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ });
+ const graph = makeGraphFor(unknown);
+
+ const result = formulateQuestion({ node: unknown, graph });
+
+ expect(result.question.toLowerCase()).not.toContain("price");
+ expect(result.question.toLowerCase()).not.toContain("pricing");
+ });
+
+ it("undefined term produces a definition question", () => {
+ const unknown = makeNode({
+ id: "n-term",
+ label: "Success criteria definition",
+ description:
+ "Need a definition of the term because the team uses it inconsistently.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ });
+
+ const result = formulateQuestion({
+ node: unknown,
+ graph: makeGraphFor(unknown),
+ });
+
+ expect(result.strategy).toBe("definition");
+ expect(result.question).toMatch(/^What does /);
+ });
+
+ it("unsupported claim produces an evidence question", () => {
+ const unknown = makeNode({
+ id: "n-claim",
+ label: "Demand claim",
+ description: "Need evidence because the claim has not been validated.",
+ kind: "reported_claim",
+ status: "provisional",
+ confidence: "low",
+ });
+
+ const result = formulateQuestion({
+ node: unknown,
+ graph: makeGraphFor(unknown),
+ });
+
+ expect(result.strategy).toBe("evidence");
+ expect(result.question).toContain("What evidence");
+ });
+
+ it("missing previous state produces a baseline question", () => {
+ const unknown = makeNode({
+ id: "n-baseline",
+ label: "Baseline conversion rate",
+ description:
+ "Need the previous baseline because the change cannot be assessed without it.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ });
+
+ const result = formulateQuestion({
+ node: unknown,
+ graph: makeGraphFor(unknown),
+ });
+
+ expect(result.strategy).toBe("baseline");
+ expect(result.question).toContain("What was the comparable state before");
+ });
+
+ it("unknown customer produces an actor/customer question", () => {
+ const unknown = makeNode({
+ id: "n-customer",
+ label: "Target customer",
+ description:
+ "Need to know the customer because value depends on who receives it.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ });
+
+ const result = formulateQuestion({
+ node: unknown,
+ graph: makeGraphFor(unknown),
+ });
+
+ expect(result.strategy).toBe("actor/customer");
+ expect(result.question).toContain(
+ "Who experiences the problem or receives the value",
+ );
+ });
+
+ it("constraint unknown produces a constraint question", () => {
+ const unknown = makeNode({
+ id: "n-constraint",
+ label: "Budget constraint",
+ description:
+ "Need the main budget constraint because it limits the available options.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ });
+
+ const result = formulateQuestion({
+ node: unknown,
+ graph: makeGraphFor(unknown),
+ });
+
+ expect(result.strategy).toBe("constraint");
+ expect(result.question).toContain("What constraint most limits");
+ });
+
+ it("question is singular and answerable", () => {
+ const unknown = makeNode({
+ id: "n-evidence",
+ label: "Evidence of demand",
+ description:
+ "Need evidence of demand because the decision depends on it.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ });
+
+ const result = formulateQuestion({
+ node: unknown,
+ graph: makeGraphFor(unknown),
+ });
+
+ expect(result.question.match(/\?/g) || []).toHaveLength(1);
+ expect(result.question.toLowerCase()).not.toContain(" and ");
+ });
+
+ it("awkward uncertainty phrasing is rejected via fallback", () => {
+ const unknown = makeNode({
+ id: "n-weird",
+ label: "Uncertainty regarding service reliability",
+ description: "Unknown service reliability.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "medium",
+ });
+
+ const result = formulateQuestion({
+ node: unknown,
+ graph: makeGraphFor(unknown),
+ });
+
+ expect(result.question).not.toContain("How should uncertainty regarding");
+ expect(result.question).not.toContain(
+ "What would resolve uncertainty regarding",
+ );
+ });
+});
diff --git a/tests/graph/question-priority-generalisation.test.js b/tests/graph/question-priority-generalisation.test.js
new file mode 100644
index 0000000..1a788a1
--- /dev/null
+++ b/tests/graph/question-priority-generalisation.test.js
@@ -0,0 +1,155 @@
+import { describe, expect, it } from "vitest";
+import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js";
+import { formulateQuestion } from "@/lib/graph/question-formulator.js";
+import { selectActiveUnknownCandidate } from "@/lib/graph/utils.js";
+import { questionPriorityGeneralisationFixtures } from "@/tests/fixtures/question-priority-generalisation.js";
+
+function clone(value) {
+ return JSON.parse(JSON.stringify(value));
+}
+
+function buildResolutionProposal(graph) {
+ const activeNode = graph.nodes.find(
+ (node) => node.id === graph.activeUnknownNodeId,
+ );
+ const placeholderCandidate = graph.nodes.find(
+ (node) => node.kind === "unknown" && node.id !== activeNode.id,
+ );
+
+ return {
+ addedNodes: [],
+ updatedNodes: [
+ {
+ nodeId: activeNode.id,
+ previousStatus: activeNode.status,
+ newStatus: "resolved",
+ previousValue: activeNode.value ?? null,
+ newValue: activeNode.value ?? "Resolved context answer",
+ reason:
+ "The resolved context unknown is treated as answered for fixture progression.",
+ },
+ ],
+ addedEdges: [],
+ removedEdgeIds: [],
+ resolvedUnknownNodeIds: [activeNode.id],
+ affectedNodeIds: [],
+ selectedQuestion: {
+ nodeId: placeholderCandidate?.id,
+ question: "Placeholder candidate question?",
+ reason: "Candidate only; deterministic selector should override it.",
+ },
+ };
+}
+
+function assertQuestionStructure(question) {
+ expect(question.match(/\?/g) || []).toHaveLength(1);
+ expect(question).not.toMatch(/\?\s*(and|or)\b/i);
+ expect(question).not.toMatch(/^What is\s+/i);
+ expect(question).toMatch(/^(What|Who|When)\b/);
+}
+
+describe("question priority generalisation", () => {
+ for (const fixture of questionPriorityGeneralisationFixtures) {
+ it(`${fixture.scenario} selects a foundational unknown and singular answerable strategy`, () => {
+ const originalGraph = clone(fixture.graph);
+ const deterministicSelection = selectActiveUnknownCandidate(
+ fixture.graph,
+ [fixture.graph.activeUnknownNodeId],
+ );
+
+ const result = applyValidatedProposal({
+ situationGraph: fixture.graph,
+ proposal: buildResolutionProposal(fixture.graph),
+ });
+
+ expect(result.success).toBe(true);
+ expect(fixture.graph).toEqual(originalGraph);
+ expect(result.graphUpdate.selectedQuestion?.question).toBe(
+ "Placeholder candidate question?",
+ );
+
+ expect(deterministicSelection.nodeId).toBe(
+ result.selectedQuestion.nodeId,
+ );
+ expect(fixture.acceptableFoundationalUnknownNodeIds).toContain(
+ result.selectedQuestion.nodeId,
+ );
+ expect(result.selectedQuestion.nodeId).not.toBe(
+ fixture.graph.nodes[fixture.graph.nodes.length - 1].id,
+ );
+
+ expect(fixture.acceptableQuestionStrategies).toContain(
+ result.selectedQuestion.strategy,
+ );
+ assertQuestionStructure(result.selectedQuestion.question);
+
+ const lowerQuestion = result.selectedQuestion.question.toLowerCase();
+ for (const topic of fixture.prohibitedFirstTopics) {
+ expect(lowerQuestion).not.toContain(topic.toLowerCase());
+ }
+
+ const selectedNode = result.updatedSituationGraph.nodes.find(
+ (node) => node.id === result.selectedQuestion.nodeId,
+ );
+ const reformulated = formulateQuestion({
+ node: selectedNode,
+ graph: result.updatedSituationGraph,
+ context: {
+ resolvedValues: ["Resolved context answer"],
+ },
+ });
+
+ expect(reformulated.question).toBe(result.selectedQuestion.question);
+ expect(clone(result.updatedSituationGraph)).toEqual(
+ result.updatedSituationGraph,
+ );
+ });
+ }
+
+ it("reports all five selected unknowns and strategies", () => {
+ const summary = questionPriorityGeneralisationFixtures.map((fixture) => {
+ const result = applyValidatedProposal({
+ situationGraph: fixture.graph,
+ proposal: buildResolutionProposal(fixture.graph),
+ });
+
+ expect(result.success).toBe(true);
+
+ return {
+ scenario: fixture.scenario,
+ nodeId: result.selectedQuestion.nodeId,
+ strategy: result.selectedQuestion.strategy,
+ };
+ });
+
+ expect(summary).toMatchInlineSnapshot(`
+ [
+ {
+ "nodeId": "hire-success-criteria",
+ "scenario": "Should we hire another engineer?",
+ "strategy": "decision criterion",
+ },
+ {
+ "nodeId": "van-reliability-threshold",
+ "scenario": "Should we replace the delivery vans?",
+ "strategy": "decision criterion",
+ },
+ {
+ "nodeId": "country-value-threshold",
+ "scenario": "Should we launch in another country?",
+ "strategy": "actor/customer",
+ },
+ {
+ "nodeId": "project-benefit-threshold",
+ "scenario": "Should we continue a project that is over budget?",
+ "strategy": "decision criterion",
+ },
+ {
+ "nodeId": "support-value-threshold",
+ "scenario": "Should we introduce a paid support tier?",
+ "strategy": "actor/customer",
+ },
+ ]
+ `);
+ });
+});
diff --git a/tests/graph/schema.test.js b/tests/graph/schema.test.js
index e8539c6..e7d727a 100644
--- a/tests/graph/schema.test.js
+++ b/tests/graph/schema.test.js
@@ -161,21 +161,56 @@ describe("graphUpdateSchema", () => {
it("validates a complete update", () => {
const node = makeNode({ id: "n2", label: "New Node" });
const edge = makeEdge({ fromNodeId: "n1", toNodeId: "n2" });
-
+
const result = graphUpdateSchema.safeParse({
addedNodes: [node],
- updatedNodes: [{ nodeId: "n1", newStatus: "resolved", previousStatus: "unknown", reason: "Question answered" }],
+ updatedNodes: [
+ {
+ nodeId: "n1",
+ newStatus: "resolved",
+ previousStatus: "unknown",
+ reason: "Question answered",
+ },
+ ],
addedEdges: [edge],
removedEdgeIds: ["e-old"],
resolvedUnknownNodeIds: ["n2"],
affectedNodeIds: ["n3"],
+ selectedQuestion: {
+ nodeId: "n2",
+ question: "What does this new node mean?",
+ reason: "A follow-up unknown remains.",
+ },
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it("allows null selectedQuestion", () => {
+ const result = graphUpdateSchema.safeParse({
+ selectedQuestion: null,
});
expect(result.success).toBe(true);
});
it("rejects update with invalid node kind in addedNodes", () => {
const invalid = graphUpdateSchema.safeParse({
- addedNodes: [{ id: "x", label: "Test", kind: "invalid_kind", description: "test", status: "unknown", confidence: "medium", value: null, unit: null, evidenceIds: [], dependsOn: [], affects: [], parentId: null, childIds: [] }],
+ addedNodes: [
+ {
+ id: "x",
+ label: "Test",
+ kind: "invalid_kind",
+ description: "test",
+ status: "unknown",
+ confidence: "medium",
+ value: null,
+ unit: null,
+ evidenceIds: [],
+ dependsOn: [],
+ affects: [],
+ parentId: null,
+ childIds: [],
+ },
+ ],
});
expect(invalid.success).toBe(false);
});
@@ -184,7 +219,9 @@ describe("graphUpdateSchema", () => {
describe("API request schemas", () => {
describe("startCaseRequestSchema", () => {
it("validates scenario field", () => {
- const result = startCaseRequestSchema.safeParse({ scenario: "Test scenario" });
+ const result = startCaseRequestSchema.safeParse({
+ scenario: "Test scenario",
+ });
expect(result.success).toBe(true);
});
@@ -195,14 +232,16 @@ describe("API request schemas", () => {
it("rejects scenario over 10000 chars", () => {
const longScenario = "a".repeat(10001);
- const result = startCaseRequestSchema.safeParse({ scenario: longScenario });
+ const result = startCaseRequestSchema.safeParse({
+ scenario: longScenario,
+ });
expect(result.success).toBe(false);
});
it("accepts optional promptVersion", () => {
- const result = startCaseRequestSchema.safeParse({
- scenario: "Test",
- promptVersion: "v0.3"
+ const result = startCaseRequestSchema.safeParse({
+ scenario: "Test",
+ promptVersion: "v0.3",
});
expect(result.success).toBe(true);
});
@@ -213,7 +252,7 @@ describe("API request schemas", () => {
const graph = makeGraph({
centralStatement: "Test scenario",
nodes: [makeNode({ id: "n1", label: "N" })],
- currentSummary: "Current state of situation"
+ currentSummary: "Current state of situation",
});
const result = updateCaseRequestSchema.safeParse({
situationGraph: graph,
@@ -235,7 +274,7 @@ describe("API request schemas", () => {
const graph = makeGraph({
centralStatement: "Test",
nodes: [makeNode({ id: "n1", label: "N" })],
- currentSummary: "Test summary"
+ currentSummary: "Test summary",
});
const result = updateCaseRequestSchema.safeParse({
situationGraph: graph,
@@ -261,7 +300,9 @@ describe("deterministic ID generation", () => {
});
it("IDs are prefixed with 'n' and short", () => {
- const id = makeNodeId("A very long label that would produce a longer hash if not truncated");
+ const id = makeNodeId(
+ "A very long label that would produce a longer hash if not truncated",
+ );
expect(id.startsWith("n")).toBe(true);
expect(id.length).toBeLessThan(15);
});
@@ -325,7 +366,7 @@ describe("helper functions", () => {
const graph = makeGraph({
centralStatement: "Test",
currentSummary: "Default summary",
- nodes: [makeNode({ id: "n1", label: "Placeholder" })]
+ nodes: [makeNode({ id: "n1", label: "Placeholder" })],
});
const result = situationGraphSchema.safeParse(graph);
expect(result.success).toBe(true);
@@ -346,19 +387,48 @@ describe("helper functions", () => {
describe("enum values completeness", () => {
it("SituationKind has all expected values", () => {
- const expected = ["observation", "reported_claim", "metric", "state", "transition", "relationship", "assumption", "unknown", "conclusion"];
+ const expected = [
+ "observation",
+ "reported_claim",
+ "metric",
+ "state",
+ "transition",
+ "relationship",
+ "assumption",
+ "unknown",
+ "conclusion",
+ ];
const actual = Object.values(SituationKind);
expect(actual).toEqual(expect.arrayContaining(expected));
});
it("SituationStatus has all expected values", () => {
- const expected = ["known", "unknown", "provisional", "supported", "weakened", "contradicted", "resolved"];
+ const expected = [
+ "known",
+ "unknown",
+ "provisional",
+ "supported",
+ "weakened",
+ "contradicted",
+ "resolved",
+ ];
const actual = Object.values(SituationStatus);
expect(actual).toEqual(expect.arrayContaining(expected));
});
it("SituationRelationship has all expected values", () => {
- const expected = ["supports", "weakens", "contradicts", "depends_on", "causes", "may_cause", "measures", "compares_with", "updates", "other"];
+ const expected = [
+ "supports",
+ "weakens",
+ "contradicts",
+ "depends_on",
+ "causes",
+ "may_cause",
+ "measures",
+ "compares_with",
+ "updates",
+ "other",
+ ];
const actual = Object.values(SituationRelationship);
expect(actual).toEqual(expect.arrayContaining(expected));
});
diff --git a/tests/graph/update-proposal.test.js b/tests/graph/update-proposal.test.js
index 6bba205..66585ef 100644
--- a/tests/graph/update-proposal.test.js
+++ b/tests/graph/update-proposal.test.js
@@ -18,6 +18,7 @@ function makeValidProposal(overrides = {}) {
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n-unknown"],
affectedNodeIds: [],
+ selectedQuestion: null,
...overrides,
};
}
@@ -117,7 +118,34 @@ describe("parseGraphUpdateProposal", () => {
expect(result.success).toBe(false);
});
- it("does not invent a next question", () => {
+ it("defaults missing selectedQuestion to null", () => {
+ const result = parseGraphUpdateProposal({
+ addedNodes: [],
+ updatedNodes: [],
+ addedEdges: [],
+ removedEdgeIds: [],
+ resolvedUnknownNodeIds: [],
+ affectedNodeIds: [],
+ });
+ expect(result.success).toBe(true);
+ expect(result.proposal.selectedQuestion).toBeNull();
+ });
+
+ it("parses a valid selectedQuestion", () => {
+ const result = parseGraphUpdateProposal(
+ makeValidProposal({
+ selectedQuestion: {
+ nodeId: "n-follow-up",
+ question: "How should commercial value be defined for this decision?",
+ reason: "A consequential unknown remains unresolved.",
+ },
+ }),
+ );
+ expect(result.success).toBe(true);
+ expect(result.proposal.selectedQuestion?.nodeId).toBe("n-follow-up");
+ });
+
+ it("does not invent a next question field outside the contract", () => {
const result = parseGraphUpdateProposal(makeValidProposal());
expect(result.proposal.nextQuestion).toBeUndefined();
});
diff --git a/tests/graph/utils.test.js b/tests/graph/utils.test.js
index 0deba4e..2e6a0b8 100644
--- a/tests/graph/utils.test.js
+++ b/tests/graph/utils.test.js
@@ -3,6 +3,7 @@ import {
validateGraphReferences,
detectDuplicateNodeIds,
detectDuplicateEdges,
+ scoreUnknownCandidate,
findDependentNodes,
findAffectedNodes,
resolveUnknownNode,
@@ -24,12 +25,22 @@ function makeTestGraph() {
// n2 depends on n1; n3 depends on n2 (transitive depends on n1)
n2.dependsOn.push(n1.id);
n3.dependsOn.push(n2.id);
-
+
// n4 is an unknown not depended on
// n5 is an unknown depended upon by n3 indirectly
- const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "depends_on" });
- const e2 = makeEdge({ id: "e2", fromNodeId: n3.id, toNodeId: n1.id, relationship: "supports" });
+ const e1 = makeEdge({
+ 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({
centralStatement: "Test graph",
@@ -55,7 +66,9 @@ describe("validateGraphReferences", () => {
graph.nodes[0].parentId = "nonexistent-parent";
const result = validateGraphReferences(graph);
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", () => {
@@ -84,7 +97,7 @@ describe("validateGraphReferences", () => {
graph.edges[0].fromNodeId = "ghost-node";
const result = validateGraphReferences(graph);
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", () => {
@@ -98,7 +111,7 @@ describe("validateGraphReferences", () => {
const graph = makeTestGraph();
graph.nodes[0].parentId = "missing";
graph.nodes[1].parentId = "also-missing";
-
+
const result = validateGraphReferences(graph);
expect(result.valid).toBe(false);
expect(result.errors.length).toBe(2);
@@ -154,9 +167,19 @@ describe("detectDuplicateEdges", () => {
it("detects duplicate edge (same from, to, relationship)", () => {
const n1 = makeNode({ id: "n1", label: "A" });
const n2 = makeNode({ id: "n2", label: "B" });
- const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
- const e2 = makeEdge({ id: "e2", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
-
+ const e1 = makeEdge({
+ 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]);
expect(dups.length).toBe(1);
});
@@ -164,9 +187,19 @@ describe("detectDuplicateEdges", () => {
it("allows same nodes with different relationship types", () => {
const n1 = makeNode({ id: "n1", label: "A" });
const n2 = makeNode({ id: "n2", label: "B" });
- const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
- const e2 = makeEdge({ id: "e2", fromNodeId: n1.id, toNodeId: n2.id, relationship: "weakens" });
-
+ const e1 = makeEdge({
+ 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]);
expect(dups.length).toBe(0);
});
@@ -174,9 +207,19 @@ describe("detectDuplicateEdges", () => {
it("detects reversed direction as different edge", () => {
const n1 = makeNode({ id: "n1", label: "A" });
const n2 = makeNode({ id: "n2", label: "B" });
- const e1 = makeEdge({ id: "e1", fromNodeId: n1.id, toNodeId: n2.id, relationship: "supports" });
- const e2 = makeEdge({ id: "e2", fromNodeId: n2.id, toNodeId: n1.id, relationship: "supports" });
-
+ const e1 = makeEdge({
+ 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]);
expect(dups.length).toBe(0);
});
@@ -229,7 +272,7 @@ describe("findDependentNodes (transitive)", () => {
for (let i = 2; i <= 10; i++) {
nodes[i - 1].dependsOn.push(nodes[0].id); // All depend on n1
}
-
+
const graph = makeGraph({
centralStatement: "Chain",
nodes,
@@ -270,7 +313,14 @@ describe("findAffectedNodes (transitive)", () => {
it("handles empty graph", () => {
// 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");
expect(affected.length).toBe(0);
});
@@ -279,7 +329,13 @@ describe("findAffectedNodes (transitive)", () => {
describe("resolveUnknownNode", () => {
it("returns success for valid node id", () => {
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.newStatus).toBe("resolved");
expect(result.reason).toBe("User confirmed");
@@ -287,7 +343,13 @@ describe("resolveUnknownNode", () => {
it("returns error for non-existent node", () => {
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.error).toContain("not found");
});
@@ -297,13 +359,25 @@ describe("resolveUnknownNode", () => {
// n5 depends on... actually let's set up properly
graph.nodes[3].affects.push("n1"); // Unknown depends on Actor A
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);
});
it("tracks previous status and value", () => {
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.newValue).toBe("confirmed_value");
});
@@ -313,7 +387,11 @@ describe("selectActiveUnknownCandidate", () => {
it("returns null when no unresolved unknowns", () => {
// makeTestGraph nodes default to kind "observation", not "unknown"
// 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({
centralStatement: "Test",
nodes: [nUnknown],
@@ -349,9 +427,21 @@ describe("selectActiveUnknownCandidate", () => {
});
it("prioritises nodes with more dependents", () => {
- const unknownA = makeNode({ id: "unknown-a", 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" });
+ const unknownA = makeNode({
+ id: "unknown-a",
+ 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");
@@ -370,7 +460,11 @@ describe("selectActiveUnknownCandidate", () => {
it("returns one candidate (not array)", () => {
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({
centralStatement: "Test",
nodes: [n1, nUnknown],
@@ -386,13 +480,152 @@ describe("selectActiveUnknownCandidate", () => {
expect(result.label).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", () => {
it("applies node additions correctly", () => {
const graph = makeTestGraph();
const newNode = makeNode({ id: "n-new", label: "New Node" });
-
+
const update = {
addedNodes: [newNode],
updatedNodes: [],
@@ -401,65 +634,69 @@ describe("applyGraphUpdate", () => {
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
};
-
+
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true);
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", () => {
const graph = makeTestGraph();
-
+
const update = {
addedNodes: [],
- updatedNodes: [{
- nodeId: "n4",
- previousStatus: "unknown",
- newStatus: "resolved",
- previousValue: null,
- newValue: "confirmed",
- reason: "Answered by user",
- }],
+ updatedNodes: [
+ {
+ nodeId: "n4",
+ previousStatus: "unknown",
+ newStatus: "resolved",
+ previousValue: null,
+ newValue: "confirmed",
+ reason: "Answered by user",
+ },
+ ],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [],
};
-
+
const result = applyGraphUpdate(graph, update);
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");
});
it("rejects update with non-existent nodeId in updatedNodes", () => {
const graph = makeTestGraph();
-
+
const update = {
addedNodes: [],
- updatedNodes: [{
- nodeId: "ghost-node",
- previousStatus: null,
- newStatus: "known",
- reason: "test",
- }],
+ updatedNodes: [
+ {
+ nodeId: "ghost-node",
+ previousStatus: null,
+ newStatus: "known",
+ reason: "test",
+ },
+ ],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
};
-
+
const result = applyGraphUpdate(graph, update);
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", () => {
const graph = makeTestGraph();
const edgeIdToRemove = graph.edges[0].id;
-
+
const update = {
addedNodes: [],
updatedNodes: [],
@@ -468,17 +705,21 @@ describe("applyGraphUpdate", () => {
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
};
-
+
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true);
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", () => {
const graph = makeTestGraph();
- const newEdge = makeEdge({ fromNodeId: "n1", toNodeId: "n4", relationship: "supports" });
-
+ const newEdge = makeEdge({
+ fromNodeId: "n1",
+ toNodeId: "n4",
+ relationship: "supports",
+ });
+
const update = {
addedNodes: [],
updatedNodes: [],
@@ -487,23 +728,23 @@ describe("applyGraphUpdate", () => {
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
};
-
+
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true);
-
+
// 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
- const fromNode = result.nodes.find(n => n.id === "n1");
- const toNode = result.nodes.find(n => n.id === "n4");
+ const fromNode = result.nodes.find((n) => n.id === "n1");
+ const toNode = result.nodes.find((n) => n.id === "n4");
expect(fromNode.childIds).toContain("n4");
expect(toNode.dependsOn).toContain("n1");
});
it("accumulates resolved node IDs", () => {
const graph = makeTestGraph();
-
+
const update = {
addedNodes: [],
updatedNodes: [],
@@ -512,7 +753,7 @@ describe("applyGraphUpdate", () => {
resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [],
};
-
+
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true);
expect(result.resolvedNodeIds).toContain("n4");
@@ -538,23 +779,25 @@ describe("applyGraphUpdate", () => {
it("rejects edges referencing non-existent nodes", () => {
const graph = makeTestGraph();
-
+
const update = {
addedNodes: [],
updatedNodes: [],
- addedEdges: [{
- id: "e-new",
- fromNodeId: "missing-node",
- toNodeId: "n1",
- relationship: "supports",
- confidence: "medium",
- description: "bad edge",
- }],
+ addedEdges: [
+ {
+ id: "e-new",
+ fromNodeId: "missing-node",
+ toNodeId: "n1",
+ relationship: "supports",
+ confidence: "medium",
+ description: "bad edge",
+ },
+ ],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
};
-
+
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(false);
});
@@ -562,7 +805,7 @@ describe("applyGraphUpdate", () => {
it("preserves nodes not mentioned in the update", () => {
const graph = makeTestGraph();
const unchangedCount = graph.nodes.length;
-
+
const update = {
addedNodes: [],
updatedNodes: [],
@@ -571,7 +814,7 @@ describe("applyGraphUpdate", () => {
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
};
-
+
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true);
expect(result.nodes.length).toBe(unchangedCount);
@@ -580,21 +823,23 @@ describe("applyGraphUpdate", () => {
it("applies multiple operations in one update", () => {
const graph = makeTestGraph();
const newNode = makeNode({ id: "n-multi", label: "Multi" });
-
+
const update = {
addedNodes: [newNode],
- updatedNodes: [{
- nodeId: "n4",
- previousStatus: "unknown",
- newStatus: "resolved",
- reason: "Multiple ops test",
- }],
+ updatedNodes: [
+ {
+ nodeId: "n4",
+ previousStatus: "unknown",
+ newStatus: "resolved",
+ reason: "Multiple ops test",
+ },
+ ],
addedEdges: [makeEdge({ fromNodeId: "n-multi", toNodeId: "n1" })],
removedEdgeIds: [graph.edges[0]?.id || ""],
resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [],
};
-
+
const result = applyGraphUpdate(graph, update);
expect(result.success).toBe(true);
});
@@ -604,7 +849,7 @@ describe("validateGraphUpdate", () => {
it("accepts a no-op update with added nodes", () => {
const graph = makeTestGraph();
const newNode = makeNode({ id: "n-new", label: "New" });
-
+
const result = validateGraphUpdate(graph, {
addedNodes: [newNode],
updatedNodes: [],
@@ -613,37 +858,39 @@ describe("validateGraphUpdate", () => {
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
});
-
+
expect(result.valid).toBe(true);
});
it("rejects update with no meaningful change", () => {
const graph = makeTestGraph();
-
+
const result = validateGraphUpdate(graph, {
addedNodes: [],
- updatedNodes: [{
- nodeId: "n1",
- previousStatus: null,
- newStatus: null,
- previousValue: null,
- newValue: null,
- reason: "No change test",
- }],
+ updatedNodes: [
+ {
+ nodeId: "n1",
+ previousStatus: null,
+ newStatus: null,
+ previousValue: null,
+ newValue: null,
+ reason: "No change test",
+ },
+ ],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
});
-
+
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", () => {
const graph = makeTestGraph();
const existingNode = graph.nodes[0];
-
+
const result = validateGraphUpdate(graph, {
addedNodes: [existingNode], // Duplicate ID
updatedNodes: [],
@@ -652,54 +899,58 @@ describe("validateGraphUpdate", () => {
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
});
-
+
expect(result.valid).toBe(false);
});
it("rejects update to non-existent node", () => {
const graph = makeTestGraph();
-
+
const result = validateGraphUpdate(graph, {
addedNodes: [],
- updatedNodes: [{
- nodeId: "ghost-node",
- previousStatus: null,
- newStatus: "known",
- reason: "test",
- }],
+ updatedNodes: [
+ {
+ nodeId: "ghost-node",
+ previousStatus: null,
+ newStatus: "known",
+ reason: "test",
+ },
+ ],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
});
-
+
expect(result.valid).toBe(false);
});
it("accepts valid status change as meaningful", () => {
const graph = makeTestGraph();
-
+
const result = validateGraphUpdate(graph, {
addedNodes: [],
- updatedNodes: [{
- nodeId: "n4",
- previousStatus: "unknown",
- newStatus: "known",
- reason: "Confirmed",
- }],
+ updatedNodes: [
+ {
+ nodeId: "n4",
+ previousStatus: "unknown",
+ newStatus: "known",
+ reason: "Confirmed",
+ },
+ ],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
});
-
+
expect(result.valid).toBe(true);
});
it("rejects oversized update (>100KB)", () => {
const graph = makeTestGraph();
const largeDescription = "x".repeat(150000);
-
+
const result = validateGraphUpdate(graph, {
addedNodes: [{ label: largeDescription }], // Will create huge JSON
updatedNodes: [],
@@ -708,14 +959,16 @@ describe("validateGraphUpdate", () => {
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
});
-
+
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", () => {
const graph = makeTestGraph();
-
+
const result = validateGraphUpdate(graph, {
addedNodes: [makeNode({ id: "n-valid", label: "Valid" })],
updatedNodes: [],
@@ -724,7 +977,7 @@ describe("validateGraphUpdate", () => {
resolvedUnknownNodeIds: [],
affectedNodeIds: [],
});
-
+
expect(result.valid).toBe(true);
expect(result.errors.length).toBe(0);
});
@@ -735,47 +988,55 @@ describe("validateGraphUpdate", () => {
describe("update lifecycle integration", () => {
it("complete update cycle: validate → apply → verify", () => {
const graph = makeTestGraph();
-
+
// Create a meaningful update
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
const validationResult = validateGraphUpdate(graph, {
addedNodes: [newNode],
- updatedNodes: [{
- nodeId: "n4",
- previousStatus: "unknown",
- newStatus: "resolved",
- reason: "Answered via follow-up question",
- }],
+ updatedNodes: [
+ {
+ nodeId: "n4",
+ previousStatus: "unknown",
+ newStatus: "resolved",
+ reason: "Answered via follow-up question",
+ },
+ ],
addedEdges: [newEdge],
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [],
});
expect(validationResult.valid).toBe(true);
-
+
// Apply
const applyResult = applyGraphUpdate(graph, {
addedNodes: [newNode],
- updatedNodes: [{
- nodeId: "n4",
- previousStatus: "unknown",
- newStatus: "resolved",
- reason: "Answered via follow-up question",
- }],
+ updatedNodes: [
+ {
+ nodeId: "n4",
+ previousStatus: "unknown",
+ newStatus: "resolved",
+ reason: "Answered via follow-up question",
+ },
+ ],
addedEdges: [newEdge],
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [],
});
-
+
expect(applyResult.success).toBe(true);
expect(applyResult.nodes.length).toBe(graph.nodes.length + 1);
expect(applyResult.edges.length).toBe(graph.edges.length + 1);
expect(applyResult.resolvedNodeIds).toContain("n4");
-
+
// Verify post-apply integrity
const postValidation = validateGraphReferences(applyResult);
expect(postValidation.valid).toBe(true);
@@ -783,10 +1044,12 @@ describe("update lifecycle integration", () => {
it("reject and retry: invalid update should be caught", () => {
const graph = makeTestGraph();
-
+
const invalidUpdate = {
addedNodes: [],
- updatedNodes: [{ nodeId: "ghost-node", newStatus: "known", reason: "test" }],
+ updatedNodes: [
+ { nodeId: "ghost-node", newStatus: "known", reason: "test" },
+ ],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [],
@@ -795,7 +1058,7 @@ describe("update lifecycle integration", () => {
// Validation should catch it
expect(validateGraphUpdate(graph, invalidUpdate).valid).toBe(false);
-
+
// Apply should also catch it
expect(applyGraphUpdate(graph, invalidUpdate).success).toBe(false);
});
@@ -803,21 +1066,23 @@ describe("update lifecycle integration", () => {
it("preserve unchanged nodes during update", () => {
const graph = makeTestGraph();
const originalNode1 = JSON.parse(JSON.stringify(graph.nodes[0]));
-
+
applyGraphUpdate(graph, {
addedNodes: [],
- updatedNodes: [{
- nodeId: "n4",
- previousStatus: "unknown",
- newStatus: "resolved",
- reason: "Test preserve",
- }],
+ updatedNodes: [
+ {
+ nodeId: "n4",
+ previousStatus: "unknown",
+ newStatus: "resolved",
+ reason: "Test preserve",
+ },
+ ],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n4"],
affectedNodeIds: [],
});
-
+
// Re-read the graph and check n1 wasn't modified
expect(graph.nodes[0].id).toBe("n1");
expect(graph.nodes[0].status).toBe("unknown"); // unchanged
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index bd825da..da23778 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -64,9 +64,15 @@ test("graph-backed one-turn update smoke test", async ({ page }) => {
timeout: 240000,
});
await expect(page.getByText(/Resolved unknowns/i)).toBeVisible();
+ await expect(page.getByText(/Newly surfaced unknowns/i)).toBeVisible();
await expect(page.getByText(/Affected nodes/i)).toBeVisible();
await expect(
- page.getByText(/No next question selected yet\./i),
+ page.getByText(/Selected Question|Next question:/i),
+ ).toBeVisible();
+ await expect(
+ page.getByText(
+ /Additional submission is disabled in this one-update prototype\./i,
+ ),
).toBeVisible();
await expect(page.getByText(/Error:/i)).toHaveCount(0);
await expect(page.getByText(/Update error:/i)).toHaveCount(0);
diff --git a/tests/ui/scenario-form.test.jsx b/tests/ui/scenario-form.test.jsx
index 1a7f994..0a29573 100644
--- a/tests/ui/scenario-form.test.jsx
+++ b/tests/ui/scenario-form.test.jsx
@@ -113,11 +113,37 @@ function makeUpdateSuccess(overrides = {}) {
value: "1.9 complaints per 100 units",
unit: null,
},
+ {
+ id: "n-next-unknown",
+ label: "Commercial value definition",
+ description: "Need a definition because the decision depends on it.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ value: null,
+ unit: null,
+ },
],
edges: [],
},
proposal: {
- addedNodes: [],
+ addedNodes: [
+ {
+ id: "n-next-unknown",
+ label: "Commercial value definition",
+ description: "Need a definition because the decision depends on it.",
+ kind: "unknown",
+ status: "unknown",
+ confidence: "high",
+ value: null,
+ unit: null,
+ evidenceIds: [],
+ dependsOn: [],
+ affects: [],
+ parentId: null,
+ childIds: [],
+ },
+ ],
updatedNodes: [
{ nodeId: "n-unknown", newStatus: "resolved", reason: "answered" },
],
@@ -125,6 +151,16 @@ function makeUpdateSuccess(overrides = {}) {
removedEdgeIds: [],
resolvedUnknownNodeIds: ["n-unknown"],
affectedNodeIds: ["n-conclusion"],
+ selectedQuestion: {
+ nodeId: "n-next-unknown",
+ question: "How should commercial value be defined for this decision?",
+ reason: "A narrower consequential uncertainty remains.",
+ },
+ },
+ selectedQuestion: {
+ nodeId: "n-next-unknown",
+ question: "How should commercial value be defined for this decision?",
+ reason: "A narrower consequential uncertainty remains.",
},
affectedNodeIds: ["n-conclusion"],
resolvedUnknownNodeIds: ["n-unknown"],
@@ -323,6 +359,20 @@ describe("graph-backed UI rendering", () => {
expect(html).toContain("Complaint rate denominator");
});
+ it("newly surfaced unknowns render", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain("Newly surfaced unknowns");
+ expect(html).toContain("Commercial value definition");
+ });
+
it("affected nodes render", () => {
const html = renderToStaticMarkup(
{
expect(html).toContain("Quality deterioration");
});
- it("no fake next question appears", () => {
+ it("renders validated next question when present", () => {
const html = renderToStaticMarkup(
,
+ );
+
+ expect(html).toContain(
+ "How should commercial value be defined for this decision?",
+ );
+ });
+
+ it("no fake next question appears when there is none", () => {
+ const html = renderToStaticMarkup(
+ ,
@@ -363,7 +435,51 @@ describe("graph-backed UI rendering", () => {
expect(html).toContain("Previous active unknown");
expect(html).toContain("Complaint rate denominator");
expect(html).toContain("New active unknown");
- expect(html).toContain("Unknown node (ID: n-next-unknown)");
+ expect(html).toContain("Commercial value definition");
+ });
+
+ it("successful update renders prior and new state together", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain("Previous active unknown");
+ expect(html).toContain("Resolved unknowns");
+ expect(html).toContain("Newly surfaced unknowns");
+ expect(html).toContain("New active unknown");
+ expect(html).toContain("Next question");
+ expect(html).toContain(
+ "How should commercial value be defined for this decision?",
+ );
+ });
+
+ it("situation graph marks newly surfaced and active unknowns", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain("newly surfaced unknown");
+ expect(html).toContain("active unknown");
+ expect(html).toContain("resolved unknown");
+ });
+
+ it("disabled follow-up form is shown only as prototype limitation", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain("How should commercial value be defined for this decision?");
});
it("raw ids remain only in collapsed proposal details", () => {
@@ -418,6 +534,30 @@ describe("graph-backed UI rendering", () => {
expect(html).toContain("bad proposal");
});
+ it("failed update does not fabricate history", () => {
+ const html = renderToStaticMarkup(
+ <>
+
+
+ >,
+ );
+
+ expect(html).toContain("Update error: Update case failed");
+ expect(html).toContain("nu_commercial_val");
+ expect(html).not.toContain("Previous active unknown");
+ expect(html).not.toContain("Resolved unknowns");
+ expect(html).not.toContain("Newly surfaced unknowns");
+ expect(html).not.toContain("New active unknown");
+ expect(html).not.toContain("Proposal details");
+ });
+
it("proposal details remain collapsible", () => {
const html = renderToStaticMarkup(
,