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;
}