refactor(reasoning): extract decision sufficiency
This commit is contained in:
+23
-273
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user