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:
@@ -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 || []),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user