refactor(reasoning): extract decision sufficiency

This commit is contained in:
2026-08-14 15:37:24 +01:00
parent c43decf5d4
commit 36b4f47097
4 changed files with 713 additions and 273 deletions
+23 -273
View File
@@ -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;
}
+233
View File
@@ -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<string>} [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<string>} [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);
}
+1
View File
@@ -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,
+456
View File
@@ -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);
});
});