refactor(reasoning): extract decision sufficiency
This commit is contained in:
+23
-273
@@ -1,4 +1,13 @@
|
|||||||
import { describeGraph } from "./builder.js";
|
import { describeGraph } from "./builder.js";
|
||||||
|
import {
|
||||||
|
countRemainingMaterialFactors,
|
||||||
|
hasRemainingMaterialFactors,
|
||||||
|
isUserConfirmationOfNoRemainingUncertainty,
|
||||||
|
shouldCloseDecision,
|
||||||
|
} from "./decision-sufficiency.js";
|
||||||
|
|
||||||
|
|
||||||
|
export { hasRemainingMaterialFactors };
|
||||||
import {
|
import {
|
||||||
assessUnknownAnswerability,
|
assessUnknownAnswerability,
|
||||||
assessUnknownAtomicity,
|
assessUnknownAtomicity,
|
||||||
@@ -57,65 +66,6 @@ function normaliseText(value) {
|
|||||||
.replace(/[^a-z0-9]+/g, " ")
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
.trim();
|
.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 = []) {
|
function buildNodeById(graph, addedNodes = []) {
|
||||||
return new Map(
|
return new Map(
|
||||||
[...graph.nodes, ...addedNodes].map((node) => [node.id, node]),
|
[...graph.nodes, ...addedNodes].map((node) => [node.id, node]),
|
||||||
@@ -3832,7 +3782,11 @@ export function applyValidatedProposal({
|
|||||||
);
|
);
|
||||||
updatedSituationGraph.reasoningState = nextReasoningState;
|
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
|
// Integrate after post-mutation / post-propagation and before
|
||||||
// final active-target / selectedQuestion selection.
|
// final active-target / selectedQuestion selection.
|
||||||
//
|
//
|
||||||
@@ -3842,9 +3796,8 @@ export function applyValidatedProposal({
|
|||||||
|
|
||||||
let closureApplied = false;
|
let closureApplied = false;
|
||||||
|
|
||||||
// Build a virtual "resolved this turn" set — at this point node statuses
|
// Build a virtual "resolved this turn" set — derive from proposalSnapshot
|
||||||
// in updatedSituationGraph have NOT been reconciled yet, so we must
|
// so shouldCloseDecision can handle same-turn resolutions before graph sync.
|
||||||
// derive what is resolved from proposalSnapshot instead of reading graph.
|
|
||||||
const pendingResolvedIds = new Set([
|
const pendingResolvedIds = new Set([
|
||||||
...proposalSnapshot.resolvedUnknownNodeIds,
|
...proposalSnapshot.resolvedUnknownNodeIds,
|
||||||
...proposalSnapshot.updatedNodes
|
...proposalSnapshot.updatedNodes
|
||||||
@@ -3852,96 +3805,6 @@ export function applyValidatedProposal({
|
|||||||
.map((u) => u.nodeId),
|
.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 || []) {
|
for (const parentNode of updatedSituationGraph.nodes || []) {
|
||||||
if (parentNode.kind !== "unknown") continue;
|
if (parentNode.kind !== "unknown") continue;
|
||||||
if (TERMINAL_STATUSES.includes(parentNode.status)) continue;
|
if (TERMINAL_STATUSES.includes(parentNode.status)) continue;
|
||||||
@@ -3949,10 +3812,14 @@ export function applyValidatedProposal({
|
|||||||
(e) => e.relationship === "contained_in" && e.toNodeId === parentNode.id,
|
(e) => e.relationship === "contained_in" && e.toNodeId === parentNode.id,
|
||||||
)) continue;
|
)) continue;
|
||||||
|
|
||||||
const remaining = checkRemainingFactorsVirtual(parentNode.id);
|
const closed = shouldCloseDecision({
|
||||||
const explicitConfirmation = isUserConfirmationOfNoRemainingUncertainty(answer);
|
decisionNodeId: parentNode.id,
|
||||||
|
graph: updatedSituationGraph,
|
||||||
|
answer,
|
||||||
|
pendingResolvedIds,
|
||||||
|
});
|
||||||
|
|
||||||
if (remaining === 0 && explicitConfirmation) {
|
if (closed) {
|
||||||
parentNode.status = "resolved";
|
parentNode.status = "resolved";
|
||||||
ensureResolvedUnknownId(proposalSnapshot, parentNode.id);
|
ensureResolvedUnknownId(proposalSnapshot, parentNode.id);
|
||||||
|
|
||||||
@@ -4611,120 +4478,3 @@ export function applyValidatedProposal({
|
|||||||
reasoningState: nextReasoningState,
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { applyValidatedProposal, hasRemainingMaterialFactors } from "@/lib/graph/apply-proposal.js";
|
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 { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||||
import {
|
import {
|
||||||
selectActiveUnknownCandidate,
|
selectActiveUnknownCandidate,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user