From 36b4f47097448f52814f2913bf338d79b814c2d2 Mon Sep 17 00:00:00 2001 From: robbond Date: Fri, 14 Aug 2026 15:37:24 +0100 Subject: [PATCH] refactor(reasoning): extract decision sufficiency --- lib/graph/apply-proposal.js | 296 ++------------- lib/graph/decision-sufficiency.js | 233 ++++++++++++ tests/graph/apply-proposal.test.js | 1 + tests/graph/decision-sufficiency.test.js | 456 +++++++++++++++++++++++ 4 files changed, 713 insertions(+), 273 deletions(-) create mode 100644 lib/graph/decision-sufficiency.js create mode 100644 tests/graph/decision-sufficiency.test.js diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js index 0741d3d..15e19e4 100644 --- a/lib/graph/apply-proposal.js +++ b/lib/graph/apply-proposal.js @@ -1,4 +1,13 @@ import { describeGraph } from "./builder.js"; +import { + countRemainingMaterialFactors, + hasRemainingMaterialFactors, + isUserConfirmationOfNoRemainingUncertainty, + shouldCloseDecision, +} from "./decision-sufficiency.js"; + + +export { hasRemainingMaterialFactors }; import { assessUnknownAnswerability, assessUnknownAtomicity, @@ -57,65 +66,6 @@ function normaliseText(value) { .replace(/[^a-z0-9]+/g, " ") .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]), @@ -3832,7 +3782,11 @@ export function applyValidatedProposal({ ); updatedSituationGraph.reasoningState = nextReasoningState; - // ── 60B.64 — explicit decision-sufficiency closure ─────────────── + // ── 60B.64 — explicit decision-sufficiency closure (extracted → decision-sufficiency.js) ──────── + + const TERMINAL_STATUSES = ["known", "resolved", "contradicted"]; + + // Integrate after post-mutation / post-propagation and before // final active-target / selectedQuestion selection. // @@ -3842,9 +3796,8 @@ export function applyValidatedProposal({ 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. + // Build a virtual "resolved this turn" set — derive from proposalSnapshot + // so shouldCloseDecision can handle same-turn resolutions before graph sync. const pendingResolvedIds = new Set([ ...proposalSnapshot.resolvedUnknownNodeIds, ...proposalSnapshot.updatedNodes @@ -3852,96 +3805,6 @@ export function applyValidatedProposal({ .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; @@ -3949,10 +3812,14 @@ export function applyValidatedProposal({ (e) => e.relationship === "contained_in" && e.toNodeId === parentNode.id, )) continue; - const remaining = checkRemainingFactorsVirtual(parentNode.id); - const explicitConfirmation = isUserConfirmationOfNoRemainingUncertainty(answer); + const closed = shouldCloseDecision({ + decisionNodeId: parentNode.id, + graph: updatedSituationGraph, + answer, + pendingResolvedIds, + }); - if (remaining === 0 && explicitConfirmation) { + if (closed) { parentNode.status = "resolved"; ensureResolvedUnknownId(proposalSnapshot, parentNode.id); @@ -4611,120 +4478,3 @@ export function applyValidatedProposal({ reasoningState: nextReasoningState, }; } - -// ── 60B.61 — decision remaining-material-factor detection ── - -const TERMINAL_STATUSES = ["known", "resolved", "contradicted"]; - -function isUnresolvedUnknown(node) { - return ( - node.kind === "unknown" && !TERMINAL_STATUSES.includes(node.status) - ); -} - -export function hasRemainingMaterialFactors(decisionNodeId, graph) { - return countRemainingMaterialFactors(decisionNodeId, graph) > 0; -} - -// ── 60B.61 — count remaining material factors for a decision node ── - -/** - * Count unresolved unknown nodes that remain material to a decision - * after all proposal updates have been applied. - * - * Routes (mirrors hasRemainingMaterialFactors but returns count): - * A: hierarchy – unresolved unknown is ancestor/descendant of decision via parentId / childIds - * B: direct dep – unresolved unknown depends_on the decision node - * C: consequence – unresolved unknown affects/may_cause/causes an option contained in the decision - * D: containment – unresolved unknown ->[contained_in]-> option ->[contained_in]-> decision - */ -export function countRemainingMaterialFactors(decisionNodeId, graph) { - const nodes = graph.nodes || []; - const edges = graph.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); - } - } - - // Build parentId upward chain for Route A - 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; - } - - // All material factor node IDs (deduplicated) - const materialFactorIds = new Set(); - - // Route A: hierarchy (parentId chain reaches the decision — unknown is descendant) - for (const node of nodes) { - if (node.id === decisionNodeId || !isUnresolvedUnknown(node)) continue; - let currentParent = getAncestorNode(node.id); - while (currentParent) { - if (currentParent.id === decisionNodeId) { - materialFactorIds.add(node.id); - break; - } - currentParent = getAncestorNode(currentParent.id); - } - } - - // Route A (cont.): direct childIds membership - for (const childId of decisionNode.childIds || []) { - const childNode = nodesById.get(childId); - if (childNode && isUnresolvedUnknown(childNode)) { - materialFactorIds.add(childId); - } - } - - // Route B: direct dependency edge TO the decision - for (const edge of edges) { - if (edge.toNodeId !== decisionNodeId || edge.relationship !== "depends_on") continue; - const source = nodesById.get(edge.fromNodeId); - if (source && isUnresolvedUnknown(source)) { - materialFactorIds.add(edge.fromNodeId); - } - } - - // Route C: consequence edge to option → contained_in → decision - 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 || !isUnresolvedUnknown(source) || !decisionOptionIds.has(targetOptionId)) continue; - materialFactorIds.add(edge.fromNodeId); - } - - // Route D: containment path — unknown ->[contained_in]-> option ->[contained_in]-> decision - for (const edge of edges) { - if (edge.relationship !== "contained_in") continue; - const fromNode = nodesById.get(edge.fromNodeId); - if (!fromNode || !isUnresolvedUnknown(fromNode)) continue; - - // Check if the target is an option contained in the decision - 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; -} diff --git a/lib/graph/decision-sufficiency.js b/lib/graph/decision-sufficiency.js new file mode 100644 index 0000000..4948a09 --- /dev/null +++ b/lib/graph/decision-sufficiency.js @@ -0,0 +1,233 @@ +// ── Decision sufficiency — pure evaluation logic ──────────────── +// Extracted from apply-proposal.js in 60B.67. +// This module owns the deterministic decision-closure rules: +// confirmation detection, remaining-factor counting, and the +// shouldCloseDecision predicate. +// Graph mutation ownership stays in apply-proposal.js per principle #4. + +const TERMINAL_STATUSES = ["known", "resolved", "contradicted"]; + +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, +]; + +// ── Pure: confirmation detection ─────────────────────────────── + +/** + * 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; +} + +// ── Pure: unresolved predicate ───────────────────────────────── + +/** + * Returns true when a node represents an unresolved unknown. + * Pure graph query — no mutation. + */ +function isUnresolvedUnknown(node) { + return ( + node.kind === "unknown" && !TERMINAL_STATUSES.includes(node.status) + ); +} + +// ── Pure: remaining-factor helpers ────────────────────────────── + +/** + * Returns true when any unresolved material factors remain for the decision. + */ +export function hasRemainingMaterialFactors(decisionNodeId, graph) { + return countRemainingMaterialFactors(decisionNodeId, graph) > 0; +} + +/** + * Count unresolved unknown nodes that remain material to a decision + * after all proposal updates have been applied. + * + * Routes (mirrors hasRemainingMaterialFactors but returns count): + * A: hierarchy – unresolved unknown is ancestor/descendant of decision via parentId / childIds + * B: direct dep – unresolved unknown depends_on the decision node + * C: consequence – unresolved unknown affects/may_cause/causes an option contained in the decision + * D: containment – unresolved unknown ->[contained_in]-> option ->[contained_in]-> decision + * + * @param {string} decisionNodeId + * @param {object} graph — SituationGraph with nodes/edges + * @param {Set} [pendingResolvedIds] — optional set of node IDs that + * should be treated as already resolved (covers same-turn resolutions + * that the graph may not yet reflect). When omitted, only the actual + * graph status is used. + */ +export function countRemainingMaterialFactors( + decisionNodeId, + graph, + pendingResolvedIds, +) { + const nodes = graph.nodes || []; + const edges = graph.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); + } + } + + // Build parentId upward chain for Route A + 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 isUnresolved(candidateNode) { + if (pendingResolvedIds && pendingResolvedIds.has(candidateNode.id)) return false; + return isUnresolvedUnknown(candidateNode); + } + + // All material factor node IDs (deduplicated) + const materialFactorIds = new Set(); + + // Route A: hierarchy (parentId chain reaches the decision — unknown is descendant) + for (const node of nodes) { + if (node.id === decisionNodeId || !isUnresolved(node)) continue; + let currentParent = getAncestorNode(node.id); + while (currentParent) { + if (currentParent.id === decisionNodeId) { + materialFactorIds.add(node.id); + break; + } + currentParent = getAncestorNode(currentParent.id); + } + } + + // Route A (cont.): direct childIds membership + for (const childId of decisionNode.childIds || []) { + const childNode = nodesById.get(childId); + if (childNode && isUnresolved(childNode)) { + materialFactorIds.add(childId); + } + } + + // Route B: direct dependency edge TO the decision + for (const edge of edges) { + if (edge.toNodeId !== decisionNodeId || edge.relationship !== "depends_on") continue; + const source = nodesById.get(edge.fromNodeId); + if (source && isUnresolved(source)) { + materialFactorIds.add(edge.fromNodeId); + } + } + + // Route C: consequence edge to option → contained_in → decision + 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 || isUnresolved(source) === false || !decisionOptionIds.has(targetOptionId)) continue; + materialFactorIds.add(edge.fromNodeId); + } + + // Route D: containment path — unknown ->[contained_in]-> option ->[contained_in]-> decision + for (const edge of edges) { + if (edge.relationship !== "contained_in") continue; + const fromNode = nodesById.get(edge.fromNodeId); + if (!fromNode || isUnresolved(fromNode) === false) continue; + + // Check if the target is an option contained in the decision + 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; +} + +// ── Pure: closure predicate ───────────────────────────────────── + +/** + * Determines whether a decision node can be closed now. + * + * Equivalent to: + * countRemainingMaterialFactors(decisionNodeId, graph) === 0 + * AND + * isUserConfirmationOfNoRemainingUncertainty(answer) + * + * @param {object} params + * @param {string} params.decisionNodeId + * @param {object} params.graph — SituationGraph (post-propagation state) + * @param {Set} [params.pendingResolvedIds] — optional set of node IDs that + * should be treated as already resolved this turn. When omitted, only actual + * graph status is consulted. + * @param {string} params.answer + */ +export function shouldCloseDecision({ + decisionNodeId, + graph, + answer, + pendingResolvedIds, +}) { + const remaining = countRemainingMaterialFactors(decisionNodeId, graph, pendingResolvedIds); + return remaining === 0 && isUserConfirmationOfNoRemainingUncertainty(answer); +} + diff --git a/tests/graph/apply-proposal.test.js b/tests/graph/apply-proposal.test.js index 94187c1..c8f8dbe 100644 --- a/tests/graph/apply-proposal.test.js +++ b/tests/graph/apply-proposal.test.js @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { applyValidatedProposal, hasRemainingMaterialFactors } from "@/lib/graph/apply-proposal.js"; +import { shouldCloseDecision, isUserConfirmationOfNoRemainingUncertainty } from "@/lib/graph/decision-sufficiency.js"; import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; import { selectActiveUnknownCandidate, diff --git a/tests/graph/decision-sufficiency.test.js b/tests/graph/decision-sufficiency.test.js new file mode 100644 index 0000000..b2dc5bc --- /dev/null +++ b/tests/graph/decision-sufficiency.test.js @@ -0,0 +1,456 @@ +import { describe, expect, it } from "vitest"; +import { + hasRemainingMaterialFactors, + shouldCloseDecision, + isUserConfirmationOfNoRemainingUncertainty, +} from "@/lib/graph/decision-sufficiency.js"; +import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; + +// ── Confirmation detection tests ────────────────────────────── + +describe("isUserConfirmationOfNoRemainingUncertainty", () => { + + it("returns true for exact confirmation phrase: no other material uncertainty remains", () => { + expect(isUserConfirmationOfNoRemainingUncertainty( + "There are no other material uncertainties between launching this year and waiting twelve months.", + )).toBe(true); + }); + + it("returns true for bounded paraphrase: no remaining material uncertainty", () => { + expect(isUserConfirmationOfNoRemainingUncertainty( + "no remaining material uncertainty exists between the options.", + )).toBe(true); + }); + + it("returns true for plural variant: no other material uncertainties remain", () => { + expect(isUserConfirmationOfNoRemainingUncertainty( + "There are no other material uncertainties between the options.", + )).toBe(true); + }); + + it("returns true for 'no further material uncertainty remains' (exact phrase via includes)", () => { + expect(isUserConfirmationOfNoRemainingUncertainty( + "I confirm: no further material uncertainty remains.", + )).toBe(true); + }); + + it("rejects contradictory wording: 'still ... material'", () => { + expect(isUserConfirmationOfNoRemainingUncertainty( + "There is still another material uncertainty.", + )).toBe(false); + }); + + it("rejects contradictory wording: 'am not saying'", () => { + expect(isUserConfirmationOfNoRemainingUncertainty( + "I am not saying there are no other material uncertainties.", + )).toBe(false); + }); + + it("rejects negated phrase: 'not claiming'", () => { + expect(isUserConfirmationOfNoRemainingUncertainty( + "I'm not claiming that all uncertainties are resolved.", + )).toBe(false); + }); + + it("rejects vague completion phrases like 'That's it'", () => { + expect(isUserConfirmationOfNoRemainingUncertainty("That's it.")).toBe(false); + }); + + it("returns false for non-confirmation answer", () => { + expect(isUserConfirmationOfNoRemainingUncertainty( + "The customer has confirmed they will sign.", + )).toBe(false); + }); + + it("returns false for null input", () => { + expect(isUserConfirmationOfNoRemainingUncertainty(null)).toBe(false); + }); + + it("returns false for empty string", () => { + expect(isUserConfirmationOfNoRemainingUncertainty("")).toBe(false); + }); + + it("returns false for non-string input", () => { + expect(isUserConfirmationOfNoRemainingUncertainty(42)).toBe(false); + }); + + it("accepts 'no remaining material differences'", () => { + expect(isUserConfirmationOfNoRemainingUncertainty( + "There are no remaining material differences between the options.", + )).toBe(true); + }); + + it("accepts 'nothing else material is uncertain'", () => { + expect(isUserConfirmationOfNoRemainingUncertainty( + "Nothing else material is uncertain about this decision.", + )).toBe(true); + }); + + it("accepts bounded regex pattern: 'no other/further material ... between'", () => { + expect(isUserConfirmationOfNoRemainingUncertainty( + "No further material difference between the choices.", + )).toBe(true); + }); +}); + +// ── Remaining-factor detection tests ────────────────────────── + +describe("hasRemainingMaterialFactors", () => { + + // Test 1 — containment-only factor (Route D) + it("returns true when unresolved unknown is contained_in an option of the decision", () => { + const decision = makeNode({ + id: "n_decision", + label: "Decision", + kind: "unknown", + status: "unknown", + }); + const optA = makeNode({ + id: "opt_a", + label: "Option A", + kind: "option", + status: "known", + }); + const factor = makeNode({ + id: "n_factor", + label: "Material factor", + kind: "unknown", + status: "unknown", + }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Decision context.", + nodes: [decision, optA, factor], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: factor.id, toNodeId: optA.id, relationship: "contained_in" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(true); + }); + + // Test 2 — same factor resolved + it("returns false when the containment-only factor is resolved", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + const factor = makeNode({ id: "n_factor", label: "R", kind: "unknown", status: "resolved" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Context.", + nodes: [decision, optA, factor], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: factor.id, toNodeId: optA.id, relationship: "contained_in" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + }); + + // Test 3 — consequence-linked factor (Route C) + it("returns true when unresolved unknown has affects/may_cause/causes to an option", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + const factor = makeNode({ id: "n_factor", label: "F", kind: "unknown", status: "unknown" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Context.", + nodes: [decision, optA, factor], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: factor.id, toNodeId: optA.id, relationship: "may_cause" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(true); + }); + + // Test 4 — direct dependency (Route B) + it("returns true when unresolved unknown directly depends_on the decision", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const depUnknown = makeNode({ id: "n_dep", label: "Dep", kind: "unknown", status: "unknown" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Context.", + nodes: [decision, depUnknown], + edges: [ + makeEdge({ fromNodeId: depUnknown.id, toNodeId: decision.id, relationship: "depends_on" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(true); + }); + + // Test 5 — hierarchy (Route A) childIds + it("returns true when the decision has an unresolved child unknown via childIds", () => { + const childUnknown = makeNode({ id: "n_child", label: "Child", kind: "unknown", status: "unknown" }); + const decision = makeNode({ + id: "n_decision", label: "D", kind: "unknown", status: "unknown", + childIds: [childUnknown.id], + }); + childUnknown.parentId = decision.id; + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Context.", + nodes: [decision, childUnknown], + edges: [], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(true); + }); + + // Test 6 — weak relationship excluded + it("returns false when only supports/measures connects unresolved node", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + const weakUnknown = makeNode({ id: "n_weak", label: "W", kind: "unknown", status: "unknown" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Context.", + nodes: [decision, optA, weakUnknown], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: weakUnknown.id, toNodeId: optA.id, relationship: "supports" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + }); + + // Test 7 — decision node itself doesn't self-count + it("returns false when the decision node itself is unknown with no factors", () => { + const decision = makeNode({ id: "n_dec_self", label: "D", kind: "unknown", status: "unknown" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Context.", + nodes: [decision], + edges: [], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + }); + + // Test 8 — status=known excluded + it("returns false when unknown has status=known", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + const knownUnknown = makeNode({ id: "n_known", label: "K", kind: "unknown", status: "known" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Context.", + nodes: [decision, optA, knownUnknown], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: knownUnknown.id, toNodeId: optA.id, relationship: "contained_in" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + }); + + // Test 9 — contradicted excluded + it("returns false when unknown has status=contradicted", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + const contra = makeNode({ id: "n_contra", label: "C", kind: "unknown", status: "contradicted" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Context.", + nodes: [decision, optA, contra], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: contra.id, toNodeId: optA.id, relationship: "contained_in" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + }); + + // Test 10 — non-unknown nodes excluded + it("returns false when only observation-level unknowns are present", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + const obsNode = makeNode({ id: "n_obs", label: "Obs", kind: "observation", status: "supported" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Context.", + nodes: [decision, optA, obsNode], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: obsNode.id, toNodeId: optA.id, relationship: "measures" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + }); + + // Test — arbitrary connectivity excluded + it("returns false when unknown connected only via non-approved edges", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + const farUnknown = makeNode({ id: "n_far", label: "F", kind: "unknown", status: "unknown" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Context.", + nodes: [decision, optA, farUnknown], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: farUnknown.id, toNodeId: optA.id, relationship: "other" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + }); +}); + +// ── shouldCloseDecision predicate tests ─────────────────────── + +describe("shouldCloseDecision", () => { + + function makeClosureGraph({ extraFactor = null } = {}) { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + + const nodes = [decision, optA]; + const edges = [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + ]; + + if (extraFactor) { + nodes.push(extraFactor); + } + + return { graph: makeGraph({ centralStatement: "test", currentSummary: "C.", nodes, edges }) }; + } + + it("returns true when no remaining factors AND explicit confirmation present", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "C.", + nodes: [decision, optA], + edges: [makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" })], + }); + + expect(shouldCloseDecision({ + decisionNodeId: decision.id, + graph, + answer: "no other material uncertainty remains", + })).toBe(true); + }); + + it("returns false when no remaining factors BUT no confirmation", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "C.", + nodes: [decision, optA], + edges: [makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" })], + }); + + expect(shouldCloseDecision({ + decisionNodeId: decision.id, + graph, + answer: "The customer signed.", + })).toBe(false); + }); + + it("returns false when remaining factors present", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + const remainingFactor = makeNode({ id: "n_rem", label: "R", kind: "unknown", status: "unknown" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "C.", + nodes: [decision, optA, remainingFactor], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: remainingFactor.id, toNodeId: optA.id, relationship: "may_cause" }), + ], + }); + + expect(shouldCloseDecision({ + decisionNodeId: decision.id, + graph, + answer: "no other material uncertainty remains", + })).toBe(false); + }); + + it("returns false when confirmation is contradictory", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "C.", + nodes: [decision, optA], + edges: [makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" })], + }); + + expect(shouldCloseDecision({ + decisionNodeId: decision.id, + graph, + answer: "There is still another material uncertainty.", + })).toBe(false); + }); + + it("uses pendingResolvedIds to treat a node as resolved", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + // factor still shows as unknown (not yet synced) + const unresolvedFactor = makeNode({ id: "n_factor", label: "F", kind: "unknown", status: "unknown" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "C.", + nodes: [decision, optA, unresolvedFactor], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: unresolvedFactor.id, toNodeId: optA.id, relationship: "contained_in" }), + ], + }); + + // Without pendingResolvedIds → remaining > 0 → false + expect(shouldCloseDecision({ + decisionNodeId: decision.id, + graph, + answer: "no other material uncertainty remains", + })).toBe(false); + + // With pendingResolvedIds including the factor → remaining = 0 + confirmed → true + const pendingResolvedIds = new Set(["n_factor"]); + expect(shouldCloseDecision({ + decisionNodeId: decision.id, + graph, + answer: "no other material uncertainty remains", + pendingResolvedIds, + })).toBe(true); + }); + + it("non-existent decision node with confirmation returns true (remaining = 0)", () => { + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "C.", + nodes: [optA], + edges: [], + }); + + // countRemainingMaterialFactors returns 0 for non-existent node + // so combined with confirmation, shouldCloseDecision returns true + // (this matches original checkRemainingFactorsVirtual semantics which also returns 0) + expect(shouldCloseDecision({ + decisionNodeId: "n_nonexistent", + graph, + answer: "no other material uncertainty remains", + })).toBe(true); + + // Without confirmation — still false + expect(shouldCloseDecision({ + decisionNodeId: "n_nonexistent", + graph, + answer: "Just confirming the customer signed.", + })).toBe(false); + }); +});