diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js index cf5b542..0741d3d 100644 --- a/lib/graph/apply-proposal.js +++ b/lib/graph/apply-proposal.js @@ -58,6 +58,64 @@ function normaliseText(value) { .trim(); } +// ── 60B.64 — raw-answer explicit sufficiency confirmation ────────── + +const CONTRADICTION_PHRASES = [ + /\bam not\b/i, + /\bnot (?:saying|claiming|asserting)\b/i, + /still \w+ material/i, +]; + +const CONFIRMATION_PHRASES = [ + "no other material uncertainty remains", + "no other material uncertainties remain", + "no further material uncertainty remains", + "no further material uncertainties remain", + "no remaining material uncertainty", + "no remaining material uncertainties", + "no remaining material difference", + "no remaining material differences", + "nothing else material is uncertain", + "nothing else material remains uncertain", +]; + +const CONFIRMATION_PATTERNS = [ + /\bno (?:other|further) material \w+?(?:\s+between\b)/i, + /\bthe\s+\w+\s+is\s+(?:complete|resolved|closed|settled)\s*(?:now|already)?/i, +]; + +/** + * Deterministic raw-answer confirmation that no other material + * uncertainty remains after a decision factor has been resolved. + * + * Returns true only when the raw user answer directly states + * sufficiency using a bounded explicit phrase family. + * + * Does NOT use: model-generated meaning, node reason text, or NLP. + */ +export function isUserConfirmationOfNoRemainingUncertainty(answer) { + if (!answer || typeof answer !== "string") return false; + + const lower = answer.toLowerCase(); + + // Reject contradictory wording first + for (const phrase of CONTRADICTION_PHRASES) { + if (phrase.test(lower)) return false; + } + + // Check explicit confirmation phrases + for (const phrase of CONFIRMATION_PHRASES) { + if (lower.includes(phrase)) return true; + } + + // Check bounded regex patterns + for (const pattern of CONFIRMATION_PATTERNS) { + if (pattern.test(lower)) return true; + } + + return false; +} + function buildNodeById(graph, addedNodes = []) { return new Map( [...graph.nodes, ...addedNodes].map((node) => [node.id, node]), @@ -3773,6 +3831,158 @@ export function applyValidatedProposal({ reasoningResolution.reasoningStateOverride, ); updatedSituationGraph.reasoningState = nextReasoningState; + + // ── 60B.64 — explicit decision-sufficiency closure ─────────────── + // Integrate after post-mutation / post-propagation and before + // final active-target / selectedQuestion selection. + // + // When all represented material factors are resolved AND the raw + // user answer explicitly confirms no further material uncertainty, + // resolve the existing parent decision in place. + + let closureApplied = false; + + // Build a virtual "resolved this turn" set — at this point node statuses + // in updatedSituationGraph have NOT been reconciled yet, so we must + // derive what is resolved from proposalSnapshot instead of reading graph. + const pendingResolvedIds = new Set([ + ...proposalSnapshot.resolvedUnknownNodeIds, + ...proposalSnapshot.updatedNodes + .filter((u) => u.newStatus === "resolved") + .map((u) => u.nodeId), + ]); + + function checkRemainingFactorsVirtual(decisionNodeId) { + const nodes = updatedSituationGraph.nodes || []; + const edges = updatedSituationGraph.edges || []; + const nodesById = new Map(nodes.map((n) => [n.id, n])); + + const decisionNode = nodesById.get(decisionNodeId); + if (!decisionNode) return 0; + + // Collect all option IDs that belong to this decision via contained_in + const decisionOptionIds = new Set(); + for (const edge of edges) { + if ( + edge.relationship === "contained_in" && + edge.toNodeId === decisionNodeId + ) { + decisionOptionIds.add(edge.fromNodeId); + } + } + + function getAncestorNode(nodeId, depth = 0) { + if (depth > 50) return null; + const n = nodesById.get(nodeId); + if (!n?.parentId) return null; + return nodesById.get(n.parentId) ?? null; + } + + function isVirtualUnresolvedUnknown(candidateNode) { + // A node is "virtually unresolved" only if it hasn't been resolved this turn + // and its current graph status isn't terminal. + if (pendingResolvedIds.has(candidateNode.id)) return false; + if (!TERMINAL_STATUSES.includes(candidateNode.status)) return true; + return false; + } + + const materialFactorIds = new Set(); + + for (const node of nodes) { + if (node.id === decisionNodeId || !isVirtualUnresolvedUnknown(node)) continue; + let currentParent = getAncestorNode(node.id); + while (currentParent) { + if (currentParent.id === decisionNodeId) { + materialFactorIds.add(node.id); + break; + } + currentParent = getAncestorNode(currentParent.id); + } + } + + for (const childId of decisionNode.childIds || []) { + const childNode = nodesById.get(childId); + if (childNode && isVirtualUnresolvedUnknown(childNode)) { + materialFactorIds.add(childId); + } + } + + for (const edge of edges) { + if (edge.toNodeId !== decisionNodeId || edge.relationship !== "depends_on") continue; + const source = nodesById.get(edge.fromNodeId); + if (source && isVirtualUnresolvedUnknown(source)) { + materialFactorIds.add(edge.fromNodeId); + } + } + + for (const edge of edges) { + if (edge.relationship !== "affects" && edge.relationship !== "may_cause" && edge.relationship !== "causes") continue; + const source = nodesById.get(edge.fromNodeId); + const targetOptionId = edge.toNodeId; + if (!source || !isVirtualUnresolvedUnknown(source) || !decisionOptionIds.has(targetOptionId)) continue; + materialFactorIds.add(edge.fromNodeId); + } + + for (const edge of edges) { + if (edge.relationship !== "contained_in") continue; + const fromNode = nodesById.get(edge.fromNodeId); + if (!fromNode || !isVirtualUnresolvedUnknown(fromNode)) continue; + for (const innerEdge of edges) { + if ( + innerEdge.relationship === "contained_in" && + innerEdge.fromNodeId === edge.toNodeId && + innerEdge.toNodeId === decisionNodeId + ) { + materialFactorIds.add(edge.fromNodeId); + break; + } + } + } + + return materialFactorIds.size; + } + + for (const parentNode of updatedSituationGraph.nodes || []) { + if (parentNode.kind !== "unknown") continue; + if (TERMINAL_STATUSES.includes(parentNode.status)) continue; + if (!(updatedSituationGraph.edges || []).some( + (e) => e.relationship === "contained_in" && e.toNodeId === parentNode.id, + )) continue; + + const remaining = checkRemainingFactorsVirtual(parentNode.id); + const explicitConfirmation = isUserConfirmationOfNoRemainingUncertainty(answer); + + if (remaining === 0 && explicitConfirmation) { + parentNode.status = "resolved"; + ensureResolvedUnknownId(proposalSnapshot, parentNode.id); + + const existingUpdate = proposalSnapshot.updatedNodes.find( + (u) => u.nodeId === parentNode.id, + ); + if (!existingUpdate) { + proposalSnapshot.updatedNodes.push({ + nodeId: parentNode.id, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: parentNode.value ?? null, + newValue: parentNode.value ?? null, + reason: + "All represented material factors resolved and raw user answer explicitly confirmed no further material uncertainty remains.", + }); + } else { + existingUpdate.newStatus = "resolved"; + if (existingUpdate.previousStatus == null) { + existingUpdate.previousStatus = "unknown"; + } + if (existingUpdate.previousValue === undefined) { + existingUpdate.previousValue = parentNode.value ?? null; + } + } + + closureApplied = true; + } + } + const resolvedCurrentTurnNodeIds = [ ...new Set(proposalSnapshot.resolvedUnknownNodeIds || []), ]; diff --git a/tests/graph/apply-proposal.test.js b/tests/graph/apply-proposal.test.js index 9afd32c..94187c1 100644 --- a/tests/graph/apply-proposal.test.js +++ b/tests/graph/apply-proposal.test.js @@ -5362,3 +5362,423 @@ describe("60B.61 — decision remaining-material-factor detection", () => { }); }); +// ── 60B.64 — explicit decision sufficiency closure ──────────────── + +describe("60B.64 — explicit decision sufficiency closure", () => { + function makeClosureDecisionFixture({ includeFallbackUnknown = false } = {}) { + const productLaunchDecision = makeNode({ + id: "n_product_launch_decision", + label: "Which option leaves us better off overall?", + description: + "Uncertainty about which of the two product-launch timing options provides superior net value for the organisation.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + const launchThisYear = makeNode({ + id: "opt_launch_this_year", + label: "Launch this year", + description: "Launch the new software product this year.", + kind: "option", + status: "known", + confidence: "high", + }); + const waitTwelveMonths = makeNode({ + id: "opt_wait_twelve_months", + label: "Wait twelve months", + description: "Wait twelve months before launching the product.", + kind: "option", + status: "known", + confidence: "high", + }); + const enterpriseCustomerSigning = makeNode({ + id: "n_enterprise_customer_signing", + label: "Prospective enterprise customer signing status", + description: + "Unknown whether one prospective enterprise customer will sign if we launch this year, because they account for approximately £700,000 of the £1.2 million expected annual revenue.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const nodes = [ + productLaunchDecision, + launchThisYear, + waitTwelveMonths, + enterpriseCustomerSigning, + ]; + const edges = [ + makeEdge({ + id: "e-opt-launch-to-dec", + fromNodeId: launchThisYear.id, + toNodeId: productLaunchDecision.id, + relationship: "contained_in", + confidence: "high", + description: "Launch this year option is a candidate for the product launch decision.", + }), + makeEdge({ + id: "e-opt-wait-to-dec", + fromNodeId: waitTwelveMonths.id, + toNodeId: productLaunchDecision.id, + relationship: "contained_in", + confidence: "high", + description: "Wait twelve months option is a candidate for the product launch decision.", + }), + makeEdge({ + id: "e-customer-signing-to-launch-option", + fromNodeId: enterpriseCustomerSigning.id, + toNodeId: launchThisYear.id, + relationship: "contained_in", + confidence: "high", + description: "Customer signing status is material to launching this year.", + }), + ]; + + if (includeFallbackUnknown) { + const fallbackUnknown = makeNode({ + id: "n_other_market_evidence", + 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", + }); + nodes.push(fallbackUnknown); + edges.push( + makeEdge({ + id: "e-fallback-to-launch-option", + fromNodeId: fallbackUnknown.id, + toNodeId: launchThisYear.id, + relationship: "may_cause", + confidence: "medium", + description: "Fallback unresolved evidence remains material to launch timing.", + }), + ); + } + + const graph = makeGraph({ + centralStatement: + "We are evaluating two product-launch timing options: launching the new software product this year or waiting twelve months.", + nodes, + edges, + activeUnknownNodeId: enterpriseCustomerSigning.id, + resolvedNodeIds: [], + currentSummary: + "Customer signing is the active material uncertainty in the product-launch decision.", + }); + + return { + graph, + ids: { + productLaunchDecision: productLaunchDecision.id, + launchThisYear: launchThisYear.id, + waitTwelveMonths: waitTwelveMonths.id, + enterpriseCustomerSigning: enterpriseCustomerSigning.id, + fallbackUnknown: includeFallbackUnknown + ? "n_other_market_evidence" + : null, + }, + }; + } + + // ── Test 1 — exact 60B.56 negative closure (raw confirmation present) ── + it("Test 1 — exact 60B.56 wording closes: customer factor resolved, decision = resolved", () => { + const { graph, ids } = makeClosureDecisionFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { + nodeId: ids.enterpriseCustomerSigning, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: + "No. 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.", + reason: "The active customer-signing unknown is resolved.", + }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning], + affectedNodeIds: [ids.productLaunchDecision], + }, + answer: + "There are no other material uncertainties between launching this year and waiting twelve months.", + }); + + expect(result.success).toBe(true); + + const customerFactor = result.updatedSituationGraph.nodes.find( + (n) => n.id === ids.enterpriseCustomerSigning, + ); + const decision = result.updatedSituationGraph.nodes.find( + (n) => n.id === ids.productLaunchDecision, + ); + + expect(customerFactor?.status).toBe("resolved"); + expect(decision?.status).toBe("resolved"); + expect(result.updatedSituationGraph.activeUnknownNodeId).toBeNull(); + expect(result.selectedQuestion).toBeNull(); + + // Decision identity preserved — no new nodes/edges added + expect(result.updatedSituationGraph.nodes).toHaveLength(4); + expect(result.updatedSituationGraph.edges).toHaveLength(3); + }); + + // ── Test 2 — positive-equivalent closure preserved ── + it("Test 2 — bounded paraphrase confirms and closes the decision", () => { + const { graph, ids } = makeClosureDecisionFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { + nodeId: ids.enterpriseCustomerSigning, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: "Customer signing confirmed for launch this year.", + reason: "The active customer-signing unknown is resolved.", + }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning], + affectedNodeIds: [ids.productLaunchDecision], + }, + answer: + "no remaining material uncertainty exists between the options.", + }); + + expect(result.success).toBe(true); + + const decision = result.updatedSituationGraph.nodes.find( + (n) => n.id === ids.productLaunchDecision, + ); + + expect(decision?.status).toBe("resolved"); + expect(result.updatedSituationGraph.activeUnknownNodeId).toBeNull(); + expect(result.selectedQuestion).toBeNull(); + }); + + // ── Test 3 — last factor resolves but no confirmation ── + it("Test 3 — without explicit raw confirmation decision remains open", () => { + const { graph, ids } = makeClosureDecisionFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { + nodeId: ids.enterpriseCustomerSigning, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: "Customer signing confirmed for launch this year.", + reason: "The active customer-signing unknown is resolved.", + }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning], + affectedNodeIds: [ids.productLaunchDecision], + }, + answer: "The customer has confirmed they will sign.", + }); + + expect(result.success).toBe(true); + + const decision = result.updatedSituationGraph.nodes.find( + (n) => n.id === ids.productLaunchDecision, + ); + + expect(decision?.status).not.toBe("resolved"); + }); + + // ── Test 4 — another genuine factor remains ── + it("Test 4 — explicit confirmation but remaining factor blocks closure", () => { + const { graph: baseGraph, ids } = makeClosureDecisionFixture({ + includeFallbackUnknown: true, + }); + + const result = applyValidatedProposal({ + situationGraph: baseGraph, + proposal: { + updatedNodes: [ + { + nodeId: ids.enterpriseCustomerSigning, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: "Customer signing confirmed.", + reason: "The active customer-signing unknown is resolved.", + }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning], + affectedNodeIds: [ids.productLaunchDecision], + }, + answer: + "There are no other material uncertainties between launching this year and waiting twelve months.", + }); + + expect(result.success).toBe(true); + + const decision = result.updatedSituationGraph.nodes.find( + (n) => n.id === ids.productLaunchDecision, + ); + + // Should NOT close because fallback unknown remains unresolved + expect(decision?.status).not.toBe("resolved"); + }); + + // ── Test 5 — contradictory confirmation wording rejected ── + it("Test 5 — contradictory wording rejects closure", () => { + const { graph, ids } = makeClosureDecisionFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { + nodeId: ids.enterpriseCustomerSigning, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: "Customer signing confirmed.", + reason: "The active customer-signing unknown is resolved.", + }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning], + affectedNodeIds: [ids.productLaunchDecision], + }, + answer: "There is still another material uncertainty.", + }); + + expect(result.success).toBe(true); + + const decision = result.updatedSituationGraph.nodes.find( + (n) => n.id === ids.productLaunchDecision, + ); + + expect(decision?.status).not.toBe("resolved"); + }); + + // ── Test 6 — negated phrase rejected ── + it("Test 6 — negated phrase rejects confirmation", () => { + const { graph, ids } = makeClosureDecisionFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { + nodeId: ids.enterpriseCustomerSigning, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: "Customer signing confirmed.", + reason: "The active customer-signing unknown is resolved.", + }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning], + affectedNodeIds: [ids.productLaunchDecision], + }, + answer: "I am not saying there are no other material uncertainties.", + }); + + expect(result.success).toBe(true); + + const decision = result.updatedSituationGraph.nodes.find( + (n) => n.id === ids.productLaunchDecision, + ); + + expect(decision?.status).not.toBe("resolved"); + }); + + // ── Test 7 — bounded supported paraphrase accepted ── + it("Test 7 — narrow equivalent phrase confirms sufficiency", () => { + const { graph, ids } = makeClosureDecisionFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { + nodeId: ids.enterpriseCustomerSigning, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: "Customer signing confirmed.", + reason: "The active customer-signing unknown is resolved.", + }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning], + affectedNodeIds: [ids.productLaunchDecision], + }, + answer: "No remaining material uncertainty exists between the options.", + }); + + expect(result.success).toBe(true); + + const decision = result.updatedSituationGraph.nodes.find( + (n) => n.id === ids.productLaunchDecision, + ); + + expect(decision?.status).toBe("resolved"); + }); + + // ── Test 8 — vague completion language excluded ── + it("Test 8 — vague phrases like 'That's it' do not confirm", () => { + const { graph, ids } = makeClosureDecisionFixture(); + + const result = applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { + nodeId: ids.enterpriseCustomerSigning, + previousStatus: "unknown", + newStatus: "resolved", + previousValue: null, + newValue: "Customer signing confirmed.", + reason: "The active customer-signing unknown is resolved.", + }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning], + affectedNodeIds: [ids.productLaunchDecision], + }, + answer: "That's it.", + }); + + expect(result.success).toBe(true); + + const decision = result.updatedSituationGraph.nodes.find( + (n) => n.id === ids.productLaunchDecision, + ); + + expect(decision?.status).not.toBe("resolved"); + }); +}); +