feat(reasoning): integrate explicit decision-sufficiency closure (60B.64)

Add two new capabilities:

1. isUserConfirmationOfNoRemainingUncertainty(answer) — bounded,
   deterministic raw-answer confirmation that no other material uncertainty
   remains after a decision factor has been resolved. Matches an explicit
   phrase family (e.g. 'no remaining material uncertainty', 'no other
   material uncertainties remain') plus two bounded regex patterns, while
   rejecting contradictory wording ('still another material uncertainty',
   'I am not saying...').

2. Decision-sufficiency closure integration point in applyValidatedProposal,
   positioned after post-mutation/post-propagation and before final
   active-target selection. When all represented material factors are
   resolved AND the raw user answer confirms sufficiency, resolves the
   existing parent decision in place (status → 'resolved') and clears
   the active unknown target.

Uses a virtual 'resolved this turn' set because node statuses have not
yet been reconciled at the integration point. Tests cover: exact fixture
wording from 60B.56, bounded paraphrases, absence-of-confirmation
(non-closure), remaining-factors (blockage), contradictory wording
(rejection), negated phrases (rejection), and vague completion language
(exclusion).
This commit is contained in:
2026-08-14 14:21:33 +01:00
parent 02b7c292a5
commit bce05f779b
2 changed files with 630 additions and 0 deletions
+210
View File
@@ -58,6 +58,64 @@ function normaliseText(value) {
.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]),
@@ -3773,6 +3831,158 @@ export function applyValidatedProposal({
reasoningResolution.reasoningStateOverride,
);
updatedSituationGraph.reasoningState = nextReasoningState;
// ── 60B.64 — explicit decision-sufficiency closure ───────────────
// Integrate after post-mutation / post-propagation and before
// final active-target / selectedQuestion selection.
//
// When all represented material factors are resolved AND the raw
// user answer explicitly confirms no further material uncertainty,
// resolve the existing parent decision in place.
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.
const pendingResolvedIds = new Set([
...proposalSnapshot.resolvedUnknownNodeIds,
...proposalSnapshot.updatedNodes
.filter((u) => u.newStatus === "resolved")
.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;
if (!(updatedSituationGraph.edges || []).some(
(e) => e.relationship === "contained_in" && e.toNodeId === parentNode.id,
)) continue;
const remaining = checkRemainingFactorsVirtual(parentNode.id);
const explicitConfirmation = isUserConfirmationOfNoRemainingUncertainty(answer);
if (remaining === 0 && explicitConfirmation) {
parentNode.status = "resolved";
ensureResolvedUnknownId(proposalSnapshot, parentNode.id);
const existingUpdate = proposalSnapshot.updatedNodes.find(
(u) => u.nodeId === parentNode.id,
);
if (!existingUpdate) {
proposalSnapshot.updatedNodes.push({
nodeId: parentNode.id,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: parentNode.value ?? null,
newValue: parentNode.value ?? null,
reason:
"All represented material factors resolved and raw user answer explicitly confirmed no further material uncertainty remains.",
});
} else {
existingUpdate.newStatus = "resolved";
if (existingUpdate.previousStatus == null) {
existingUpdate.previousStatus = "unknown";
}
if (existingUpdate.previousValue === undefined) {
existingUpdate.previousValue = parentNode.value ?? null;
}
}
closureApplied = true;
}
}
const resolvedCurrentTurnNodeIds = [
...new Set(proposalSnapshot.resolvedUnknownNodeIds || []),
];