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 || []),
];
+420
View File
@@ -5362,3 +5362,423 @@ describe("60B.61 — decision remaining-material-factor detection", () => {
});
});
// ── 60B.64 — explicit decision sufficiency closure ────────────────
describe("60B.64 — explicit decision sufficiency closure", () => {
function makeClosureDecisionFixture({ includeFallbackUnknown = false } = {}) {
const productLaunchDecision = makeNode({
id: "n_product_launch_decision",
label: "Which option leaves us better off overall?",
description:
"Uncertainty about which of the two product-launch timing options provides superior net value for the organisation.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const launchThisYear = makeNode({
id: "opt_launch_this_year",
label: "Launch this year",
description: "Launch the new software product this year.",
kind: "option",
status: "known",
confidence: "high",
});
const waitTwelveMonths = makeNode({
id: "opt_wait_twelve_months",
label: "Wait twelve months",
description: "Wait twelve months before launching the product.",
kind: "option",
status: "known",
confidence: "high",
});
const enterpriseCustomerSigning = makeNode({
id: "n_enterprise_customer_signing",
label: "Prospective enterprise customer signing status",
description:
"Unknown whether one prospective enterprise customer will sign if we launch this year, because they account for approximately £700,000 of the £1.2 million expected annual revenue.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const nodes = [
productLaunchDecision,
launchThisYear,
waitTwelveMonths,
enterpriseCustomerSigning,
];
const edges = [
makeEdge({
id: "e-opt-launch-to-dec",
fromNodeId: launchThisYear.id,
toNodeId: productLaunchDecision.id,
relationship: "contained_in",
confidence: "high",
description: "Launch this year option is a candidate for the product launch decision.",
}),
makeEdge({
id: "e-opt-wait-to-dec",
fromNodeId: waitTwelveMonths.id,
toNodeId: productLaunchDecision.id,
relationship: "contained_in",
confidence: "high",
description: "Wait twelve months option is a candidate for the product launch decision.",
}),
makeEdge({
id: "e-customer-signing-to-launch-option",
fromNodeId: enterpriseCustomerSigning.id,
toNodeId: launchThisYear.id,
relationship: "contained_in",
confidence: "high",
description: "Customer signing status is material to launching this year.",
}),
];
if (includeFallbackUnknown) {
const fallbackUnknown = makeNode({
id: "n_other_market_evidence",
label: "Other market evidence gap",
description:
"Need other market evidence because the remaining launch case still depends on it.",
kind: "unknown",
status: "unknown",
confidence: "high",
});
nodes.push(fallbackUnknown);
edges.push(
makeEdge({
id: "e-fallback-to-launch-option",
fromNodeId: fallbackUnknown.id,
toNodeId: launchThisYear.id,
relationship: "may_cause",
confidence: "medium",
description: "Fallback unresolved evidence remains material to launch timing.",
}),
);
}
const graph = makeGraph({
centralStatement:
"We are evaluating two product-launch timing options: launching the new software product this year or waiting twelve months.",
nodes,
edges,
activeUnknownNodeId: enterpriseCustomerSigning.id,
resolvedNodeIds: [],
currentSummary:
"Customer signing is the active material uncertainty in the product-launch decision.",
});
return {
graph,
ids: {
productLaunchDecision: productLaunchDecision.id,
launchThisYear: launchThisYear.id,
waitTwelveMonths: waitTwelveMonths.id,
enterpriseCustomerSigning: enterpriseCustomerSigning.id,
fallbackUnknown: includeFallbackUnknown
? "n_other_market_evidence"
: null,
},
};
}
// ── Test 1 — exact 60B.56 negative closure (raw confirmation present) ──
it("Test 1 — exact 60B.56 wording closes: customer factor resolved, decision = resolved", () => {
const { graph, ids } = makeClosureDecisionFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.enterpriseCustomerSigning,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue:
"No. The enterprise customer has now confirmed in writing that they will not sign if we launch this year, so the £700,000 of expected annual revenue from them will not be received.",
reason: "The active customer-signing unknown is resolved.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning],
affectedNodeIds: [ids.productLaunchDecision],
},
answer:
"There are no other material uncertainties between launching this year and waiting twelve months.",
});
expect(result.success).toBe(true);
const customerFactor = result.updatedSituationGraph.nodes.find(
(n) => n.id === ids.enterpriseCustomerSigning,
);
const decision = result.updatedSituationGraph.nodes.find(
(n) => n.id === ids.productLaunchDecision,
);
expect(customerFactor?.status).toBe("resolved");
expect(decision?.status).toBe("resolved");
expect(result.updatedSituationGraph.activeUnknownNodeId).toBeNull();
expect(result.selectedQuestion).toBeNull();
// Decision identity preserved — no new nodes/edges added
expect(result.updatedSituationGraph.nodes).toHaveLength(4);
expect(result.updatedSituationGraph.edges).toHaveLength(3);
});
// ── Test 2 — positive-equivalent closure preserved ──
it("Test 2 — bounded paraphrase confirms and closes the decision", () => {
const { graph, ids } = makeClosureDecisionFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.enterpriseCustomerSigning,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Customer signing confirmed for launch this year.",
reason: "The active customer-signing unknown is resolved.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning],
affectedNodeIds: [ids.productLaunchDecision],
},
answer:
"no remaining material uncertainty exists between the options.",
});
expect(result.success).toBe(true);
const decision = result.updatedSituationGraph.nodes.find(
(n) => n.id === ids.productLaunchDecision,
);
expect(decision?.status).toBe("resolved");
expect(result.updatedSituationGraph.activeUnknownNodeId).toBeNull();
expect(result.selectedQuestion).toBeNull();
});
// ── Test 3 — last factor resolves but no confirmation ──
it("Test 3 — without explicit raw confirmation decision remains open", () => {
const { graph, ids } = makeClosureDecisionFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.enterpriseCustomerSigning,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Customer signing confirmed for launch this year.",
reason: "The active customer-signing unknown is resolved.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning],
affectedNodeIds: [ids.productLaunchDecision],
},
answer: "The customer has confirmed they will sign.",
});
expect(result.success).toBe(true);
const decision = result.updatedSituationGraph.nodes.find(
(n) => n.id === ids.productLaunchDecision,
);
expect(decision?.status).not.toBe("resolved");
});
// ── Test 4 — another genuine factor remains ──
it("Test 4 — explicit confirmation but remaining factor blocks closure", () => {
const { graph: baseGraph, ids } = makeClosureDecisionFixture({
includeFallbackUnknown: true,
});
const result = applyValidatedProposal({
situationGraph: baseGraph,
proposal: {
updatedNodes: [
{
nodeId: ids.enterpriseCustomerSigning,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Customer signing confirmed.",
reason: "The active customer-signing unknown is resolved.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning],
affectedNodeIds: [ids.productLaunchDecision],
},
answer:
"There are no other material uncertainties between launching this year and waiting twelve months.",
});
expect(result.success).toBe(true);
const decision = result.updatedSituationGraph.nodes.find(
(n) => n.id === ids.productLaunchDecision,
);
// Should NOT close because fallback unknown remains unresolved
expect(decision?.status).not.toBe("resolved");
});
// ── Test 5 — contradictory confirmation wording rejected ──
it("Test 5 — contradictory wording rejects closure", () => {
const { graph, ids } = makeClosureDecisionFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.enterpriseCustomerSigning,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Customer signing confirmed.",
reason: "The active customer-signing unknown is resolved.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning],
affectedNodeIds: [ids.productLaunchDecision],
},
answer: "There is still another material uncertainty.",
});
expect(result.success).toBe(true);
const decision = result.updatedSituationGraph.nodes.find(
(n) => n.id === ids.productLaunchDecision,
);
expect(decision?.status).not.toBe("resolved");
});
// ── Test 6 — negated phrase rejected ──
it("Test 6 — negated phrase rejects confirmation", () => {
const { graph, ids } = makeClosureDecisionFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.enterpriseCustomerSigning,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Customer signing confirmed.",
reason: "The active customer-signing unknown is resolved.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning],
affectedNodeIds: [ids.productLaunchDecision],
},
answer: "I am not saying there are no other material uncertainties.",
});
expect(result.success).toBe(true);
const decision = result.updatedSituationGraph.nodes.find(
(n) => n.id === ids.productLaunchDecision,
);
expect(decision?.status).not.toBe("resolved");
});
// ── Test 7 — bounded supported paraphrase accepted ──
it("Test 7 — narrow equivalent phrase confirms sufficiency", () => {
const { graph, ids } = makeClosureDecisionFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.enterpriseCustomerSigning,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Customer signing confirmed.",
reason: "The active customer-signing unknown is resolved.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning],
affectedNodeIds: [ids.productLaunchDecision],
},
answer: "No remaining material uncertainty exists between the options.",
});
expect(result.success).toBe(true);
const decision = result.updatedSituationGraph.nodes.find(
(n) => n.id === ids.productLaunchDecision,
);
expect(decision?.status).toBe("resolved");
});
// ── Test 8 — vague completion language excluded ──
it("Test 8 — vague phrases like 'That's it' do not confirm", () => {
const { graph, ids } = makeClosureDecisionFixture();
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.enterpriseCustomerSigning,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: "Customer signing confirmed.",
reason: "The active customer-signing unknown is resolved.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning],
affectedNodeIds: [ids.productLaunchDecision],
},
answer: "That's it.",
});
expect(result.success).toBe(true);
const decision = result.updatedSituationGraph.nodes.find(
(n) => n.id === ids.productLaunchDecision,
);
expect(decision?.status).not.toBe("resolved");
});
});