From ade445e453841e4154d55733c160d3673e1a25ea Mon Sep 17 00:00:00 2001 From: robbond Date: Mon, 17 Aug 2026 08:06:08 +0100 Subject: [PATCH] fix(reasoning): reconcile evidence and preserve deterministic continuation --- lib/graph/apply-proposal.js | 250 +++++++++- lib/graph/question-formulator.js | 9 + lib/graph/utils.js | 13 + tests/graph/apply-proposal.test.js | 600 ++++++++++++++++++++++++ tests/graph/question-formulator.test.js | 423 ++++++++++++++++- tests/graph/utils.test.js | 86 ++++ 6 files changed, 1373 insertions(+), 8 deletions(-) diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js index a0e5492..01116d2 100644 --- a/lib/graph/apply-proposal.js +++ b/lib/graph/apply-proposal.js @@ -610,6 +610,148 @@ function buildAffectedNodeIds(graph, proposal) { return [...affected]; } +function normaliseReconciliationSemanticText(value) { + return normaliseText(String(value || "")).replace(/\b(the|a|an|its|this|that)\b/g, " ").replace(/\s+/g, " ").trim(); +} + +function collectLocalReconciliationCandidateIds(graph, anchorNodeId) { + const candidateIds = new Set(); + const anchorDecisionId = findContainingDecisionNodeId(graph, anchorNodeId); + const anchorNode = findNodeById(graph, anchorNodeId); + + for (const edge of graph.edges || []) { + if (edge.fromNodeId === anchorNodeId) candidateIds.add(edge.toNodeId); + if (edge.toNodeId === anchorNodeId) candidateIds.add(edge.fromNodeId); + } + + if (anchorDecisionId) { + for (const edge of graph.edges || []) { + if (edge.relationship !== "contained_in") continue; + + if (edge.toNodeId === anchorDecisionId) { + const optionId = edge.fromNodeId; + candidateIds.add(optionId); + for (const innerEdge of graph.edges || []) { + if (innerEdge.toNodeId === optionId) { + candidateIds.add(innerEdge.fromNodeId); + } + } + } + } + } + + if (anchorNode?.parentId) { + candidateIds.add(anchorNode.parentId); + for (const node of graph.nodes || []) { + if (node.parentId === anchorNode.parentId) candidateIds.add(node.id); + } + } + + candidateIds.delete(anchorNodeId); + return [...candidateIds]; +} + +function classifyReconciliationRelation({ anchorNode, candidateNode, graph }) { + if (!anchorNode || !candidateNode) return "ambiguous"; + if (candidateNode.kind !== "unknown") return "unrelated"; + + const anchorText = normaliseReconciliationSemanticText( + `${anchorNode.label} ${anchorNode.description}`, + ); + const candidateText = normaliseReconciliationSemanticText( + `${candidateNode.label} ${candidateNode.description}`, + ); + + const sharedDecision = + findContainingDecisionNodeId(graph, anchorNode.id) && + findContainingDecisionNodeId(graph, anchorNode.id) === + findContainingDecisionNodeId(graph, candidateNode.id); + + const anchorMentionsSigning = /\b(sign|signing|contract status)\b/.test(anchorText); + const candidateMentionsDecisionTiming = /\b(when|timing|timeline|make its decision|make their decision)\b/.test(candidateText); + const candidateMentionsPendingStatus = + /\b(currently unknown|pending|contract|status)\b/.test(candidateText) && + /\b(sign|signing|contract|status)\b/.test(candidateText); + const candidateMentionsPureTiming = + candidateMentionsDecisionTiming && !candidateMentionsPendingStatus; + + if ( + sharedDecision && + anchorMentionsSigning && + candidateMentionsPendingStatus && + !candidateMentionsPureTiming + ) { + return "same_proposition"; + } + + if (sharedDecision && candidateMentionsPureTiming) { + return "same_entity_different_dimension"; + } + + const structurallyConsequential = (graph.edges || []).some( + (edge) => + edge.fromNodeId === candidateNode.id && + ["depends_on", "causes", "may_cause", "affects", "contained_in"].includes(edge.relationship), + ); + const candidateMentionsRevenue = /\b(revenue|700000|700k|received)\b/.test(candidateText); + if (structurallyConsequential && candidateMentionsRevenue) { + return "derived_consequence"; + } + + const downstreamToDecision = (graph.edges || []).some( + (edge) => edge.fromNodeId === candidateNode.id && edge.toNodeId === findContainingDecisionNodeId(graph, anchorNode.id), + ); + if (downstreamToDecision) { + return "downstream_dependency"; + } + + return "unrelated"; +} + +function reconcileDefinitiveEvidenceAcrossLocalGraph(graph, proposalSnapshot) { + const definitiveNodeIds = (proposalSnapshot?.updatedNodes || []) + .filter((update) => update.newStatus === "resolved") + .map((update) => update.nodeId); + + for (const nodeId of definitiveNodeIds) { + const anchorNode = findNodeById(graph, nodeId); + if (!anchorNode) continue; + + const candidateIds = collectLocalReconciliationCandidateIds(graph, nodeId); + for (const candidateId of candidateIds) { + const candidateNode = findNodeById(graph, candidateId); + if (!candidateNode) continue; + + const relation = classifyReconciliationRelation({ + anchorNode, + candidateNode, + graph, + }); + + if (relation === "same_proposition") { + candidateNode.status = "resolved"; + if (anchorNode.value !== undefined) { + candidateNode.value = anchorNode.value ?? candidateNode.value ?? null; + if (typeof anchorNode.value === "string" && anchorNode.value.trim().length > 0) { + candidateNode.description = anchorNode.value; + } + } + if (!graph.resolvedNodeIds.includes(candidateNode.id)) { + graph.resolvedNodeIds.push(candidateNode.id); + } + } + + if (relation === "derived_consequence") { + candidateNode.status = "resolved"; + candidateNode.value = anchorNode.value ?? candidateNode.value ?? null; + if (!graph.resolvedNodeIds.includes(candidateNode.id)) { + graph.resolvedNodeIds.push(candidateNode.id); + } + } + } + } +} + function buildChangesApplied(proposal, affectedNodeIds) { return { addedNodeCount: proposal.addedNodes.length, @@ -1909,6 +2051,33 @@ function selectedQuestionBelongsToChild(graph, selectedQuestion) { return Boolean(findNodeById(graph, selectedQuestion.nodeId)?.parentId); } +function findContainingDecisionNodeId(graph, nodeId) { + if (!graph || !nodeId) return null; + const visited = new Set(); + const queue = [nodeId]; + + while (queue.length > 0) { + const currentNodeId = queue.shift(); + if (!currentNodeId || visited.has(currentNodeId)) continue; + visited.add(currentNodeId); + + for (const edge of graph.edges || []) { + if (edge.relationship !== "contained_in") continue; + if (edge.fromNodeId !== currentNodeId) continue; + + const targetNode = findNodeById(graph, edge.toNodeId); + if (!targetNode) continue; + if (targetNode.kind === "unknown") { + return targetNode.id; + } + + queue.push(targetNode.id); + } + } + + return null; +} + function normaliseQuestionText(question) { return normaliseText(String(question || "").replace(/\?/g, " ")); } @@ -2342,11 +2511,26 @@ function selectPatternCompatibleUnknownCandidate({ } const excluded = new Set(excludedNodeIds || []); + const additionallyExcludedNodeIds = new Set(); + + if (activePattern === "decision") { + for (const node of graph.nodes || []) { + if (node.kind !== "unknown") continue; + if (excluded.has(node.id)) continue; + if (!isSelectableUnresolvedUnknown(graph, node.id)) continue; + if (!hasIncomingContainedInEdge(node.id, graph.edges)) continue; + if (hasRemainingMaterialFactors(node.id, graph)) { + additionallyExcludedNodeIds.add(node.id); + } + } + } + const incompatibleNodeIds = (graph.nodes || []) .filter( (node) => node.kind === "unknown" && !excluded.has(node.id) && + !additionallyExcludedNodeIds.has(node.id) && isSelectableUnresolvedUnknown(graph, node.id), ) .filter( @@ -2365,6 +2549,7 @@ function selectPatternCompatibleUnknownCandidate({ ...new Set([ ...(resolvedNodeIds || []), ...incompatibleNodeIds, + ...additionallyExcludedNodeIds, ...excludedNodeIds, ]), ]); @@ -4007,6 +4192,13 @@ export function applyValidatedProposal({ const resolvedCurrentTurnNodeIds = [ ...new Set(proposalSnapshot.resolvedUnknownNodeIds || []), ]; + + reconcileDefinitiveEvidenceAcrossLocalGraph( + updatedSituationGraph, + proposalSnapshot, + ); + + let postPropagationIncompatibleNodeIds = []; let postPropagationCompatibilityFailures = []; let postPropagationReplacementActions = []; @@ -4284,7 +4476,18 @@ export function applyValidatedProposal({ (node) => node.id === deterministicSelection.nodeId, ) : null; - const formulatedQuestion = selectedNode + const selectedNodeContainingDecisionNodeId = selectedNode + ? findContainingDecisionNodeId(updatedSituationGraph, selectedNode.id) + : null; + const suppressDecisionSufficiencyConfirmation = Boolean( + selectedNodeContainingDecisionNodeId && + selectedNodeContainingDecisionNodeId !== selectedNode?.id && + hasRemainingMaterialFactors( + selectedNodeContainingDecisionNodeId, + updatedSituationGraph, + ), + ); + let formulatedQuestion = selectedNode ? formulateQuestion({ node: selectedNode, graph: updatedSituationGraph, @@ -4295,6 +4498,7 @@ export function applyValidatedProposal({ (value) => typeof value === "string" && value.trim().length > 0, ), selectionState: deterministicSelection, + suppressDecisionSufficiencyConfirmation, }, }) : null; @@ -4303,6 +4507,43 @@ export function applyValidatedProposal({ const plainLanguageNormalisations = formulatedQuestion?.plainLanguageNormalisations ?? []; + if ( + deterministicSelection?.status === "selected" && + selectedNode && + formulatedQuestion?.selectedQuestionTemplate === + "decision_threshold_sufficiency_confirmation" + ) { + const containingDecisionNodeId = selectedNodeContainingDecisionNodeId; + + if ( + containingDecisionNodeId && + containingDecisionNodeId !== selectedNode.id && + hasRemainingMaterialFactors(containingDecisionNodeId, updatedSituationGraph) + ) { + const reformulatedSpecificQuestion = formulateQuestion({ + node: selectedNode, + graph: updatedSituationGraph, + context: { + resolvedValues: validatedProposal.updatedNodes + .map((update) => update.newValue) + .filter( + (value) => typeof value === "string" && value.trim().length > 0, + ), + selectionState: { + ...deterministicSelection, + reason: + "Preserved the specific unresolved material factor because its containing decision still has remaining material factors after the update.", + }, + suppressDecisionSufficiencyConfirmation: true, + }, + }); + + if (reformulatedSpecificQuestion) { + formulatedQuestion = reformulatedSpecificQuestion; + } + } + } + const finalSelectedQuestion = deterministicSelection?.status === "ambiguous" ? { @@ -4360,6 +4601,12 @@ export function applyValidatedProposal({ repeatedSelection.nodeId !== deterministicSelection.nodeId ) { deterministicSelection = repeatedSelection; + } else if ( + finalSelectedQuestion?.selectedQuestionTemplate !== + "decision_threshold_sufficiency_confirmation" + ) { + // Keep the existing specific-factor continuation when repetition rejection + // cannot produce a structurally better replacement. } } @@ -4382,6 +4629,7 @@ export function applyValidatedProposal({ (value) => typeof value === "string" && value.trim().length > 0, ), selectionState: deterministicSelection, + suppressDecisionSufficiencyConfirmation, }, }) : null; diff --git a/lib/graph/question-formulator.js b/lib/graph/question-formulator.js index f5cac5a..47f83e8 100644 --- a/lib/graph/question-formulator.js +++ b/lib/graph/question-formulator.js @@ -1098,6 +1098,13 @@ function isPrioritisationPatternCandidate(node, graph, relatedNodes = []) { ); } +function isStructuralDecisionNode(node, graph) { + if (!node || node.kind !== "unknown") return false; + return (graph?.edges || []).some( + (edge) => edge.relationship === "contained_in" && edge.toNodeId === node.id, + ); +} + export function selectReasoningPattern({ node, graph, context = {} }) { const relatedNodes = collectRelatedNodes(node, graph); const patternContext = { @@ -2007,6 +2014,8 @@ export function formulateQuestion({ node, graph, context = {} }) { // Detected transiently from existing state; no persisted field required. if ( reasoningPatternSelection.pattern === "decision" && + context.suppressDecisionSufficiencyConfirmation !== true && + isStructuralDecisionNode(node, graph) && node.status !== "known" && node.status !== "resolved" && node.status !== "contradicted" && diff --git a/lib/graph/utils.js b/lib/graph/utils.js index 22b0760..b074693 100644 --- a/lib/graph/utils.js +++ b/lib/graph/utils.js @@ -318,6 +318,19 @@ function classifyCandidateOrdering(candidates) { }; } + if (topStructuralCandidates.length > 0) { + return { + displayOrder, + best, + leadingCandidates: topStructuralCandidates, + status: "selected", + tieType: "complete_unresolved_tie", + usedAlphabeticalOrdering: true, + reason: + "Leading candidates remained tied after score, structural, and semantic checks, so the stable deterministic display order was used as the final fallback.", + }; + } + return { displayOrder, best: null, diff --git a/tests/graph/apply-proposal.test.js b/tests/graph/apply-proposal.test.js index 997fc0e..6cd681c 100644 --- a/tests/graph/apply-proposal.test.js +++ b/tests/graph/apply-proposal.test.js @@ -4504,6 +4504,191 @@ describe("60B.43 — terminal post-mutation target is cleared after valid decisi }; } + function makeRichReconciliationFixture() { + const productLaunchDecision = makeNode({ + id: "n_product_launch_decision_rich", + label: "Which option leaves us better off overall?", + description: + "Uncertainty about whether launching this year or waiting twelve months leaves the organisation better off overall.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const launchThisYear = makeNode({ + id: "opt_launch_this_year_rich", + label: "Launch this year", + description: + "Launch this year with approximately £1.2M expected annual revenue and around £300,000 additional support and implementation cost.", + kind: "option", + status: "known", + confidence: "high", + }); + const waitTwelveMonths = makeNode({ + id: "opt_wait_twelve_months_rich", + label: "Wait twelve months", + description: + "Wait twelve months to reduce immediate support cost and improve the product, but delay revenue and risk competitor movement.", + kind: "option", + status: "known", + confidence: "high", + }); + const customerSigningPrimary = makeNode({ + id: "n_enterprise_customer_signing_rich", + label: "Prospective enterprise customer signing status", + description: + "Unknown whether the large enterprise customer will sign if we launch this year, because they account for approximately £700,000 of expected annual revenue.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const customerSigningSecondary = makeNode({ + id: "n_customer_contract_timeline_pending", + label: "Enterprise customer contract status is currently unknown", + description: + "The status of the large enterprise customer's contract is currently unknown, including whether signing is still pending on this year's launch timeline.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const customerDecisionTimeline = makeNode({ + id: "n_customer_decision_timeline", + label: "When will the enterprise customer make its decision?", + description: + "Unknown when the enterprise customer will make its decision, and the timing could still affect launch planning.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const revenueConsequence = makeNode({ + id: "n_customer_revenue_consequence", + label: "Whether £700,000 of annual revenue from the enterprise customer will be received", + description: + "Unknown whether the £700,000 annual revenue linked to the enterprise customer will be received if we launch this year.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const commercialViability = makeNode({ + id: "n_launch_viability_without_anchor_customer", + label: "Is launch commercially viable without the £700,000?", + description: + "Unknown whether launching this year is still commercially viable without the £700,000 enterprise-customer revenue.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const competitorRisk = makeNode({ + id: "n_competitor_move_first_risk", + label: "Could competitors move first during a 12-month delay?", + description: + "Unknown whether competitors could move first if launch is delayed by twelve months, and this could still materially change the decision.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const graph = makeGraph({ + centralStatement: + "We need to decide whether there is enough evidence to launch this new software product this year or whether waiting twelve months is safer.", + nodes: [ + productLaunchDecision, + launchThisYear, + waitTwelveMonths, + customerSigningPrimary, + customerSigningSecondary, + customerDecisionTimeline, + revenueConsequence, + commercialViability, + competitorRisk, + ], + edges: [ + makeEdge({ + id: "e-rich-launch-to-decision", + fromNodeId: launchThisYear.id, + toNodeId: productLaunchDecision.id, + relationship: "contained_in", + confidence: "high", + description: "Launch-this-year is an option in the decision.", + }), + makeEdge({ + id: "e-rich-wait-to-decision", + fromNodeId: waitTwelveMonths.id, + toNodeId: productLaunchDecision.id, + relationship: "contained_in", + confidence: "high", + description: "Wait-twelve-months is an option in the decision.", + }), + makeEdge({ + id: "e-rich-primary-customer-to-launch", + fromNodeId: customerSigningPrimary.id, + toNodeId: launchThisYear.id, + relationship: "contained_in", + confidence: "high", + description: "Primary customer-signing uncertainty is material to launching this year.", + }), + makeEdge({ + id: "e-rich-secondary-customer-to-launch", + fromNodeId: customerSigningSecondary.id, + toNodeId: launchThisYear.id, + relationship: "contained_in", + confidence: "high", + description: "Secondary customer contract-status uncertainty is also material to launching this year.", + }), + makeEdge({ + id: "e-rich-timeline-to-launch", + fromNodeId: customerDecisionTimeline.id, + toNodeId: launchThisYear.id, + relationship: "depends_on", + confidence: "medium", + description: "Launch planning depends in part on when the customer decides.", + }), + makeEdge({ + id: "e-rich-revenue-consequence-to-launch", + fromNodeId: revenueConsequence.id, + toNodeId: launchThisYear.id, + relationship: "depends_on", + confidence: "high", + description: "Receiving the £700,000 revenue depends on whether the customer signs.", + }), + makeEdge({ + id: "e-rich-viability-to-decision", + fromNodeId: commercialViability.id, + toNodeId: productLaunchDecision.id, + relationship: "depends_on", + confidence: "high", + description: "The decision depends on whether launch is viable without the anchor customer.", + }), + makeEdge({ + id: "e-rich-competitor-risk-to-wait", + fromNodeId: competitorRisk.id, + toNodeId: waitTwelveMonths.id, + relationship: "contained_in", + confidence: "high", + description: "Competitor move-first risk is material to the wait option.", + }), + ], + activeUnknownNodeId: productLaunchDecision.id, + resolvedNodeIds: [], + currentSummary: + "The decision is open; customer signing is unresolved; customer contract status and decision timing remain represented separately; revenue consequence, commercial viability, and competitor timing remain material uncertainties.", + }); + + return { + graph, + ids: { + productLaunchDecision: productLaunchDecision.id, + launchThisYear: launchThisYear.id, + waitTwelveMonths: waitTwelveMonths.id, + customerSigningPrimary: customerSigningPrimary.id, + customerSigningSecondary: customerSigningSecondary.id, + customerDecisionTimeline: customerDecisionTimeline.id, + revenueConsequence: revenueConsequence.id, + commercialViability: commercialViability.id, + competitorRisk: competitorRisk.id, + }, + }; + } + it("accepts the 60B.37-shaped closure proposal, resolves the customer factor, and clears the terminal decision target after mutation", () => { const { graph, ids } = makeProductLaunchClosureFixture(); @@ -4617,6 +4802,421 @@ describe("60B.43 — terminal post-mutation target is cleared after valid decisi expect(result.selectedQuestion?.nodeId).not.toBe(ids.productLaunchDecision); }); + it("60B.81 regression — after materially informative sufficiency answer, a specific remaining material factor should outrank repeated sufficiency confirmation", () => { + const { graph, ids } = makeProductLaunchClosureFixture({ + includeFallbackUnknown: true, + }); + + const previousQuestion = + "Is there anything else material that could change which option is better?"; + const answer = + "The enterprise customer has now confirmed in writing that they will not sign if we launch this year, so the £700,000 of expected annual revenue from them will not be received."; + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { + nodeId: ids.enterpriseCustomerSigning, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: answer, + reason: + "The answer definitively resolves the enterprise-customer signing factor with materially new negative evidence.", + }, + { + nodeId: ids.launchThisYear, + previousStatus: "known", + newStatus: "known", + previousValue: null, + newValue: + "If we launch this year, the £700,000 expected annual revenue from the enterprise customer will not be received.", + reason: + "Update the launch-this-year option to reflect the resolved financial consequence of the customer not signing.", + }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning], + affectedNodeIds: [ids.launchThisYear, ids.productLaunchDecision], + selectedQuestion: { + nodeId: ids.productLaunchDecision, + question: previousQuestion, + reason: + "The prior turn asked the bounded sufficiency-confirmation question at the decision level.", + }, + }, + previousQuestion, + answer, + }); + + expect(result.success).toBe(true); + + const resolvedCustomer = result.updatedSituationGraph.nodes.find( + (node) => node.id === ids.enterpriseCustomerSigning, + ); + const remainingFactor = result.updatedSituationGraph.nodes.find( + (node) => node.id === ids.fallbackUnknown, + ); + const launchOption = result.updatedSituationGraph.nodes.find( + (node) => node.id === ids.launchThisYear, + ); + + expect(resolvedCustomer?.status).toBe("resolved"); + expect(remainingFactor?.status).toBe("unknown"); + expect(result.updatedSituationGraph.resolvedNodeIds).toContain( + ids.enterpriseCustomerSigning, + ); + expect(result.updatedSituationGraph.resolvedNodeIds).not.toContain( + ids.productLaunchDecision, + ); + expect(result.newActiveUnknownNodeId).toBe(ids.fallbackUnknown); + expect(result.selectedQuestion?.nodeId).toBe(ids.fallbackUnknown); + expect(result.selectedQuestion?.selectedQuestionTemplate).not.toBe( + "decision_threshold_sufficiency_confirmation", + ); + expect(result.selectedQuestion?.question).not.toBe(previousQuestion); + expect(launchOption?.value).toContain("£700,000"); + expect(launchOption?.value).toContain("will not be received"); + }); + + it("60B.82 richer live regression — definitive customer rejection must supersede stale overlapping customer-unknown state and must not repeat sufficiency question", () => { + const productLaunchDecision = makeNode({ + id: "n_product_launch_decision_rich", + label: "Which option leaves us better off overall?", + description: + "Uncertainty about whether launching this year or waiting twelve months leaves the organisation better off overall.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const launchThisYear = makeNode({ + id: "opt_launch_this_year_rich", + label: "Launch this year", + description: + "Launch this year with approximately £1.2M expected annual revenue and around £300,000 additional support and implementation cost.", + kind: "option", + status: "known", + confidence: "high", + }); + const waitTwelveMonths = makeNode({ + id: "opt_wait_twelve_months_rich", + label: "Wait twelve months", + description: + "Wait twelve months to reduce immediate support cost and improve the product, but delay revenue and risk competitor movement.", + kind: "option", + status: "known", + confidence: "high", + }); + const customerSigningPrimary = makeNode({ + id: "n_enterprise_customer_signing_rich", + label: "Prospective enterprise customer signing status", + description: + "Unknown whether the large enterprise customer will sign if we launch this year, because they account for approximately £700,000 of expected annual revenue.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const customerSigningSecondary = makeNode({ + id: "n_customer_contract_timeline_pending", + label: "Enterprise customer contract timing still pending", + description: + "The status of the large enterprise customer's contract is currently unknown, including whether signing is still pending on this year's launch timeline.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const delayingBenefit = makeNode({ + id: "n_delay_improvement_benefit", + label: "Quantifiable benefit of delaying launch for product improvements versus immediate market entry", + description: + "Unknown how much extra value twelve months of product improvement would create relative to launching now, and this could still change which option is better.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const competitorRisk = makeNode({ + id: "n_competitor_move_first_risk", + label: "External firms that could move first if the product is delayed", + description: + "Unknown which external firms could move first if launch is delayed, and the competitive impact could still materially change the decision.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const graph = makeGraph({ + centralStatement: + "We need to decide whether there is enough evidence to launch this new software product this year or whether waiting twelve months is safer.", + nodes: [ + productLaunchDecision, + launchThisYear, + waitTwelveMonths, + customerSigningPrimary, + customerSigningSecondary, + delayingBenefit, + competitorRisk, + ], + edges: [ + makeEdge({ + id: "e-rich-launch-to-decision", + fromNodeId: launchThisYear.id, + toNodeId: productLaunchDecision.id, + relationship: "contained_in", + confidence: "high", + description: "Launch-this-year is an option in the decision.", + }), + makeEdge({ + id: "e-rich-wait-to-decision", + fromNodeId: waitTwelveMonths.id, + toNodeId: productLaunchDecision.id, + relationship: "contained_in", + confidence: "high", + description: "Wait-twelve-months is an option in the decision.", + }), + makeEdge({ + id: "e-rich-primary-customer-to-launch", + fromNodeId: customerSigningPrimary.id, + toNodeId: launchThisYear.id, + relationship: "contained_in", + confidence: "high", + description: "Primary customer-signing uncertainty is material to launching this year.", + }), + makeEdge({ + id: "e-rich-secondary-customer-to-launch", + fromNodeId: customerSigningSecondary.id, + toNodeId: launchThisYear.id, + relationship: "contained_in", + confidence: "high", + description: "Secondary customer contract-status uncertainty is also material to launching this year.", + }), + makeEdge({ + id: "e-rich-delay-benefit-to-wait", + fromNodeId: delayingBenefit.id, + toNodeId: waitTwelveMonths.id, + relationship: "contained_in", + confidence: "high", + description: "The quantified benefit of delay is material to the wait option.", + }), + makeEdge({ + id: "e-rich-competitor-risk-to-wait", + fromNodeId: competitorRisk.id, + toNodeId: waitTwelveMonths.id, + relationship: "contained_in", + confidence: "high", + description: "Competitor move-first risk is material to the wait option.", + }), + ], + activeUnknownNodeId: productLaunchDecision.id, + resolvedNodeIds: [], + currentSummary: + "The decision is open; customer signing is unresolved; delaying-launch benefits and competitor timing remain material uncertainties.", + }); + + const previousQuestion = + "Is there anything else material that could change which option is better?"; + const answer = + "The enterprise customer has now confirmed in writing that they will not sign if we launch this year, so the £700,000 of expected annual revenue from them will not be received."; + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { + nodeId: customerSigningPrimary.id, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: answer, + reason: + "The answer definitively resolves the primary enterprise-customer signing factor with negative evidence.", + }, + { + nodeId: launchThisYear.id, + previousStatus: "known", + newStatus: "known", + previousValue: null, + newValue: + "If we launch this year, the £700,000 expected annual revenue from the enterprise customer will not be received.", + reason: + "Reflect the financial impact of the confirmed customer rejection in the launch-this-year option.", + }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [customerSigningPrimary.id], + affectedNodeIds: [launchThisYear.id, productLaunchDecision.id], + selectedQuestion: { + nodeId: productLaunchDecision.id, + question: previousQuestion, + reason: + "The previous live turn was the sufficiency confirmation question at the decision level.", + }, + }, + previousQuestion, + answer, + }); + + expect(result.success).toBe(true); + + const primaryCustomerNode = result.updatedSituationGraph.nodes.find( + (node) => node.id === customerSigningPrimary.id, + ); + const staleCustomerNode = result.updatedSituationGraph.nodes.find( + (node) => node.id === customerSigningSecondary.id, + ); + + expect(primaryCustomerNode?.status).toBe("resolved"); + expect(primaryCustomerNode?.value).toContain("will not sign"); + + expect(staleCustomerNode?.status).not.toBe("unknown"); + expect(staleCustomerNode?.description?.toLowerCase()).not.toContain( + "currently unknown", + ); + expect(staleCustomerNode?.description?.toLowerCase()).not.toContain( + "pending", + ); + + expect(result.selectedQuestion?.selectedQuestionTemplate).not.toBe( + "decision_threshold_sufficiency_confirmation", + ); + expect(result.selectedQuestion?.question).not.toBe(previousQuestion); + expect([ + delayingBenefit.id, + competitorRisk.id, + ]).toContain(result.selectedQuestion?.nodeId); + }); + + describe("60B.83 — reconciliation safety envelope before generic implementation", () => { + function applyRichCustomerRejectionUpdate() { + const { graph, ids } = makeRichReconciliationFixture(); + const previousQuestion = + "Is there anything else material that could change which option is better?"; + const answer = + "The enterprise customer has now confirmed in writing that they will not sign if we launch this year, so the £700,000 of expected annual revenue from them will not be received."; + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { + nodeId: ids.customerSigningPrimary, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: answer, + reason: + "The answer definitively resolves the primary enterprise-customer signing factor with negative evidence.", + }, + { + nodeId: ids.launchThisYear, + previousStatus: "known", + newStatus: "known", + previousValue: null, + newValue: + "If we launch this year, the £700,000 expected annual revenue from the enterprise customer will not be received.", + reason: + "Reflect the financial impact of the confirmed customer rejection in the launch-this-year option.", + }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [ids.customerSigningPrimary], + affectedNodeIds: [ids.launchThisYear, ids.productLaunchDecision], + selectedQuestion: { + nodeId: ids.productLaunchDecision, + question: previousQuestion, + reason: + "The previous live turn was the sufficiency confirmation question at the decision level.", + }, + }, + previousQuestion, + answer, + }); + + return { result, ids, previousQuestion, answer }; + } + + it("1 SAME_PROPOSITION — same-proposition sibling should no longer remain an active unresolved contradiction", () => { + const { result, ids } = applyRichCustomerRejectionUpdate(); + expect(result.success).toBe(true); + + const primaryNode = result.updatedSituationGraph.nodes.find( + (node) => node.id === ids.customerSigningPrimary, + ); + const siblingNode = result.updatedSituationGraph.nodes.find( + (node) => node.id === ids.customerSigningSecondary, + ); + + expect(primaryNode?.status).toBe("resolved"); + expect(siblingNode?.status).not.toBe("unknown"); + }); + + it("2 SAME_ENTITY_DIFFERENT_DIMENSION — timeline node remains unresolved", () => { + const { result, ids } = applyRichCustomerRejectionUpdate(); + expect(result.success).toBe(true); + + const timelineNode = result.updatedSituationGraph.nodes.find( + (node) => node.id === ids.customerDecisionTimeline, + ); + + expect(timelineNode?.status).toBe("unknown"); + }); + + it("3 DERIVED_CONSEQUENCE — structurally linked revenue consequence may be updated from explicit dependency semantics", () => { + const { result, ids } = applyRichCustomerRejectionUpdate(); + expect(result.success).toBe(true); + + const revenueNode = result.updatedSituationGraph.nodes.find( + (node) => node.id === ids.revenueConsequence, + ); + + expect(revenueNode?.status).not.toBe("unknown"); + }); + + it("4 DOWNSTREAM_DEPENDENCY — commercial viability without the £700k remains unresolved", () => { + const { result, ids } = applyRichCustomerRejectionUpdate(); + expect(result.success).toBe(true); + + const viabilityNode = result.updatedSituationGraph.nodes.find( + (node) => node.id === ids.commercialViability, + ); + + expect(viabilityNode?.status).toBe("unknown"); + }); + + it("5 UNRELATED — competitor factor remains unresolved and untouched", () => { + const { result, ids } = applyRichCustomerRejectionUpdate(); + expect(result.success).toBe(true); + + const competitorNode = result.updatedSituationGraph.nodes.find( + (node) => node.id === ids.competitorRisk, + ); + + expect(competitorNode?.status).toBe("unknown"); + expect(competitorNode?.value ?? null).toBe(null); + }); + + it("6 QUESTION_CONTINUATION — next question should continue with a specific remaining factor, not repeated sufficiency confirmation", () => { + const { result, ids, previousQuestion } = applyRichCustomerRejectionUpdate(); + expect(result.success).toBe(true); + + expect([ + ids.customerDecisionTimeline, + ids.commercialViability, + ids.competitorRisk, + ]).toContain(result.selectedQuestion?.nodeId); + expect(result.selectedQuestion?.selectedQuestionTemplate).not.toBe( + "decision_threshold_sufficiency_confirmation", + ); + expect(result.selectedQuestion?.question).not.toBe(previousQuestion); + }); + }); + it("returns no active target and no final question when only terminal unknowns remain after a valid mutation", () => { const graph = makeGraph({ centralStatement: "Known-only terminal graph after mutation", diff --git a/tests/graph/question-formulator.test.js b/tests/graph/question-formulator.test.js index 3d3f922..41a32e2 100644 --- a/tests/graph/question-formulator.test.js +++ b/tests/graph/question-formulator.test.js @@ -8,6 +8,7 @@ import { } from "@/lib/graph/question-formulator.js"; import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; import { + countRemainingMaterialFactors, hasRemainingMaterialFactors, isUserConfirmationOfNoRemainingUncertainty, } from "@/lib/graph/decision-sufficiency.js"; @@ -1110,10 +1111,24 @@ describe("formulateQuestion", () => { confidence: "medium", childIds: [], }); + const option = makeNode({ + id: "opt_launch_product_x", + label: "Launch product X", + description: "Launch product X now.", + kind: "option", + status: "known", + confidence: "high", + }); const graph = makeGraphFor(decision, { - nodes: [], - edges: [], + nodes: [option], + edges: [ + makeEdge({ + fromNodeId: option.id, + toNodeId: decision.id, + relationship: "contained_in", + }), + ], }); const result = formulateQuestion({ @@ -1141,8 +1156,25 @@ describe("formulateQuestion", () => { status: "unknown", confidence: "medium", }); + const option = makeNode({ + id: "opt_launch_product_x_2", + label: "Launch product X", + description: "Launch product X now.", + kind: "option", + status: "known", + confidence: "high", + }); - const graph = makeGraphFor(decision, { nodes: [], edges: [] }); + const graph = makeGraphFor(decision, { + nodes: [option], + edges: [ + makeEdge({ + fromNodeId: option.id, + toNodeId: decision.id, + relationship: "contained_in", + }), + ], + }); const result = formulateQuestion({ node: decision, @@ -1299,10 +1331,24 @@ describe("formulateQuestion", () => { confidence: "medium", childIds: [factor.id], }); + const option = makeNode({ + id: "opt_launch_product_x_7", + label: "Launch product X", + description: "Launch product X now.", + kind: "option", + status: "known", + confidence: "high", + }); - const graph = makeGraphFor(factor, { - nodes: [decision], - edges: [], + const graph = makeGraphFor(decision, { + nodes: [factor, option], + edges: [ + makeEdge({ + fromNodeId: option.id, + toNodeId: decision.id, + relationship: "contained_in", + }), + ], }); // Verified: zero remaining factors (factor is resolved) @@ -1329,8 +1375,25 @@ describe("formulateQuestion", () => { status: "unknown", confidence: "medium", }); + const option = makeNode({ + id: "opt_launch_product_x_8", + label: "Launch product X", + description: "Launch product X now.", + kind: "option", + status: "known", + confidence: "high", + }); - const graph = makeGraphFor(decision, { nodes: [], edges: [] }); + const graph = makeGraphFor(decision, { + nodes: [option], + edges: [ + makeEdge({ + fromNodeId: option.id, + toNodeId: decision.id, + relationship: "contained_in", + }), + ], + }); const result = formulateQuestion({ node: decision, @@ -1550,3 +1613,349 @@ describe("60B.75 — real decision detection for sufficiency question", () => { expect(result.question).toContain("anything else material"); }); }); + +// ── 60B.80 — sufficiency re-selection after proposal application (no false State B) ──── + +describe("60B.80 — no false State B after proposal adds unresolved material factor", () => { + it("Test 1 — new unresolved unknown with parentId correctly counted as remaining factor", () => { + // Start: decision with NO edges → hasRemainingMaterialFactors = 0 → State B triggers + const decision = makeNode({ + id: "n_launch_decision", + label: "Whether to launch product X", + description: "Launch vs not launch decision for product X.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const option = makeNode({ + id: "opt_launch_decision_80_1", + label: "Launch product X", + description: "Launch product X now.", + kind: "option", + status: "known", + confidence: "high", + }); + + const graph = makeGraphFor(decision, { + nodes: [option], + edges: [ + makeEdge({ + fromNodeId: option.id, + toNodeId: decision.id, + relationship: "contained_in", + }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + + // State B correctly triggers here (no remaining factors) + const initialResult = formulateQuestion({ + node: decision, + graph, + context: { resolvedValues: [] }, + }); + + expect(initialResult.selectedQuestionTemplate).toBe( + "decision_threshold_sufficiency_confirmation", + ); + + // Now simulate what happens AFTER a proposal adds a NEW unresolved unknown + // The new unknown represents customer signing uncertainty + const newUnknown = makeNode({ + id: "n_customer_signing", + label: "Customer signing commitment timeline", + description: "Uncertainty about whether the key customer will sign.", + kind: "unknown", + status: "unknown", + confidence: "high", + parentId: "n_launch_decision", + }); + + // Update decision to include new unknown in childIds and add it to graph + const updatedDecision = { ...decision, childIds: [newUnknown.id] }; + const updatedGraph = makeGraph({ + centralStatement: "Launch decision context", + nodes: [updatedDecision, newUnknown], + edges: [], + activeUnknownNodeId: decision.id, + resolvedNodeIds: [], + currentSummary: "Updated summary", + }); + + // After the proposal adds a material factor, count should be 1 + expect(countRemainingMaterialFactors(updatedDecision.id, updatedGraph)).toBe(1); + }); + + it("Test 2 — formulated question does NOT re-trigger State B when new unresolved factor exists", () => { + // Setup: decision + new unresolved unknown with proper parentId link + const decision = makeNode({ + id: "n_launch_decision", + label: "Whether to launch product X", + description: "Launch vs not launch.", + kind: "unknown", + status: "unknown", + confidence: "medium", + childIds: ["n_customer_signing"], + }); + + const customerSigning = makeNode({ + id: "n_customer_signing", + label: "Customer signing commitment timeline", + description: "Uncertainty about whether the key customer will sign.", + kind: "unknown", + status: "unknown", + confidence: "high", + parentId: "n_launch_decision", + }); + + const graph = makeGraph({ + centralStatement: "Launch decision context", + nodes: [decision, customerSigning], + edges: [], + activeUnknownNodeId: decision.id, + resolvedNodeIds: [], + currentSummary: "Updated summary", + }); + + // Core invariant: countRemainingMaterialFactors must detect the new factor + expect(countRemainingMaterialFactors(decision.id, graph)).toBe(1); + + // When we formulate a question for the decision (e.g., after proposal application), + // State B should NOT trigger because remaining factors exist. + const result = formulateQuestion({ + node: decision, + graph, + context: { resolvedValues: [] }, + }); + + // The key assertion: template must NOT be sufficiency_confirmation + // When there ARE remaining material factors, the engine should produce + // a normal decision_threshold question (seeking the missing factor), not + // the sufficiency confirmation that State B would wrongly provide. + expect(result.selectedQuestionTemplate).not.toBe( + "decision_threshold_sufficiency_confirmation", + ); + }); + + it("Test 3 — customer signing resolution preserves new remaining factors correctly", () => { + // Setup: two unknowns, both unresolved, decision in State B (would trigger) + const decision = makeNode({ + id: "n_launch_decision", + label: "Whether to launch product X", + description: "Launch vs not launch.", + kind: "unknown", + status: "unknown", + confidence: "medium", + childIds: ["n_customer_signing"], + }); + + const customerSigning = makeNode({ + id: "n_customer_signing", + label: "Customer signing commitment timeline", + description: "Uncertainty about whether the key customer will sign.", + kind: "unknown", + status: "unknown", + confidence: "high", + parentId: "n_launch_decision", + }); + + const graph = makeGraph({ + centralStatement: "Launch decision context", + nodes: [decision, customerSigning], + edges: [], + activeUnknownNodeId: decision.id, + resolvedNodeIds: [], + currentSummary: "Updated summary", + }); + + // customer signing remains unresolved — count = 1 → no State B + expect(countRemainingMaterialFactors(decision.id, graph)).toBe(1); + + const result = formulateQuestion({ + node: decision, + graph, + context: { resolvedValues: [] }, + }); + + // Should NOT produce sufficiency confirmation because customer signing remains unresolved + expect(result.selectedQuestionTemplate).not.toBe( + "decision_threshold_sufficiency_confirmation", + ); + }); + + it("Test 4 — after resolving ONE factor, State B correctly re-triggers only when ALL factors resolved", () => { + const decision = makeNode({ + id: "n_launch_decision", + label: "Whether to launch product X", + description: "Launch vs not launch.", + kind: "unknown", + status: "unknown", + confidence: "medium", + childIds: ["n_customer_signing"], + }); + + // customerSigning starts unresolved, then gets resolved (status changes) + const customerSigning = makeNode({ + id: "n_customer_signing", + label: "Customer signing commitment timeline", + description: "Uncertainty about whether the key customer will sign.", + kind: "unknown", + status: "resolved", // resolved via node.status (State B checks this) + confidence: "high", + parentId: "n_launch_decision", + }); + + const graph = makeGraph({ + centralStatement: "Launch decision context", + nodes: [ + decision, + customerSigning, + makeNode({ + id: "opt_launch_decision_80_4", + label: "Launch product X", + description: "Launch product X now.", + kind: "option", + status: "known", + confidence: "high", + }), + ], + edges: [ + makeEdge({ + fromNodeId: "opt_launch_decision_80_4", + toNodeId: decision.id, + relationship: "contained_in", + }), + ], + activeUnknownNodeId: decision.id, + resolvedNodeIds: ["n_customer_signing"], + currentSummary: "Updated summary", + }); + + // After resolution (status = "resolved"): hasRemainingMaterialFactors = 0 → State B SHOULD trigger + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + + const result = formulateQuestion({ + node: decision, + graph, + context: { resolvedValues: [] }, + }); + + // Now State B should correctly trigger because all factors are resolved + expect(result.selectedQuestionTemplate).toBe( + "decision_threshold_sufficiency_confirmation", + ); + }); + + it("Test 5 — explicit confirmation prevents State B even with zero remaining factors", () => { + const decision = makeNode({ + id: "n_launch_decision", + label: "Whether to launch product X", + description: "Launch vs not launch.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const graph = makeGraphFor(decision, { nodes: [], edges: [] }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + + // User confirms sufficiency during State B question + const result = formulateQuestion({ + node: decision, + graph, + context: { + resolvedValues: [ + "no other material uncertainty remains", + "Customer signing is the last factor and they will sign.", + ], + }, + }); + + // Should NOT be sufficiency confirmation because user already confirmed + expect(result.selectedQuestionTemplate).not.toBe( + "decision_threshold_sufficiency_confirmation", + ); + }); + + it("60B.84 — State B must not fire for a specific factor node merely because it is inside a decision context", () => { + const decision = makeNode({ + id: "n_launch_decision_factor_guard", + label: "Which option leaves us better off overall?", + description: "Decision about whether to launch this year or wait.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const launchOption = makeNode({ + id: "opt_launch_factor_guard", + label: "Launch this year", + description: "Launch this year.", + kind: "option", + status: "known", + confidence: "high", + }); + + const selectedFactor = makeNode({ + id: "n_specific_remaining_factor", + label: "Other market evidence gap", + description: + "Need other market evidence because the remaining launch case still depends on it.", + kind: "unknown", + status: "unknown", + confidence: "high", + }); + + const additionalFactor = makeNode({ + id: "n_additional_remaining_factor", + label: "Competitor response uncertainty", + description: + "Unknown whether competitors would move first if launch is delayed.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const graph = makeGraph({ + centralStatement: + "We need to decide whether to launch this year or wait twelve months.", + nodes: [decision, launchOption, selectedFactor, additionalFactor], + edges: [ + makeEdge({ + fromNodeId: launchOption.id, + toNodeId: decision.id, + relationship: "contained_in", + }), + makeEdge({ + fromNodeId: selectedFactor.id, + toNodeId: launchOption.id, + relationship: "may_cause", + }), + makeEdge({ + fromNodeId: additionalFactor.id, + toNodeId: launchOption.id, + relationship: "contained_in", + }), + ], + activeUnknownNodeId: selectedFactor.id, + resolvedNodeIds: [], + currentSummary: "Specific unresolved factors remain.", + }); + + const pattern = selectReasoningPattern({ node: selectedFactor, graph }); + expect(pattern.pattern).toBe("decision"); + + const result = formulateQuestion({ + node: selectedFactor, + graph, + context: { resolvedValues: [] }, + }); + + expect(selectedFactor.id).not.toBe(decision.id); + expect(result.selectedQuestionTemplate).not.toBe( + "decision_threshold_sufficiency_confirmation", + ); + }); +}); diff --git a/tests/graph/utils.test.js b/tests/graph/utils.test.js index f7954b9..4c1e1e3 100644 --- a/tests/graph/utils.test.js +++ b/tests/graph/utils.test.js @@ -912,6 +912,92 @@ describe("applyGraphUpdate", () => { }); }); +describe("selectActiveUnknownCandidate deterministic tie resolution", () => { + it("returns a stable non-null winner when two eligible candidates tie on top score", () => { + const decision = makeNode({ + id: "n_decision_tie", + label: "Which option leaves us better off overall?", + description: "Decision context", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const launch = makeNode({ + id: "opt_launch_tie", + label: "Launch this year", + description: "Launch this year.", + kind: "option", + status: "known", + confidence: "high", + }); + const timeline = makeNode({ + id: "n_customer_decision_timeline", + label: "When will the enterprise customer make its decision?", + description: + "Unknown when the enterprise customer will make its decision, and the timing could still affect launch planning.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const viability = makeNode({ + id: "n_launch_viability_without_anchor_customer", + label: "Is launch commercially viable without the £700,000?", + description: + "Unknown whether launching this year is still commercially viable without the £700,000 enterprise-customer revenue.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const competitor = makeNode({ + id: "n_competitor_move_first_risk", + label: "Could competitors move first during a 12-month delay?", + description: + "Unknown whether competitors could move first if launch is delayed by twelve months.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const graph = makeGraph({ + centralStatement: "Launch decision", + nodes: [decision, launch, timeline, viability, competitor], + edges: [ + makeEdge({ + fromNodeId: launch.id, + toNodeId: decision.id, + relationship: "contained_in", + }), + makeEdge({ + fromNodeId: timeline.id, + toNodeId: launch.id, + relationship: "depends_on", + }), + makeEdge({ + fromNodeId: viability.id, + toNodeId: decision.id, + relationship: "depends_on", + }), + makeEdge({ + fromNodeId: competitor.id, + toNodeId: launch.id, + relationship: "contained_in", + }), + ], + activeUnknownNodeId: null, + resolvedNodeIds: [], + currentSummary: "Tie case", + }); + + const first = selectActiveUnknownCandidate(graph, []); + const second = selectActiveUnknownCandidate(graph, []); + + expect(first?.status).toBe("selected"); + expect(first?.nodeId).toBeTruthy(); + expect(second?.status).toBe("selected"); + expect(second?.nodeId).toBe(first?.nodeId); + }); +}); + describe("validateGraphUpdate", () => { it("accepts a no-op update with added nodes", () => { const graph = makeTestGraph();