Files
confidence-engine/lib/graph/decision-sufficiency.js
T

273 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── Decision sufficiency — pure evaluation logic ────────────────
// Extracted from apply-proposal.js in 60B.67.
// This module owns the deterministic decision-closure rules:
// confirmation detection, remaining-factor counting, and the
// shouldCloseDecision predicate.
// Graph mutation ownership stays in apply-proposal.js per principle #4.
const TERMINAL_STATUSES = ["known", "resolved", "contradicted"];
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,
];
// ── Pure: confirmation detection ───────────────────────────────
/**
* 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;
}
// ── Pure: decision-closure authority question predicate ───────────
/**
* Determines whether a question explicitly asks the user whether any
* material uncertainty remains at the parent-decision level — i.e. the
* question grants closure authority when answered with confirmation.
*
* Bounded to the decision_threshold sufficiency-confirmation semantic family.
* Focused questions (supplier, budget, etc.) do NOT match even if they
* receive a confirming answer.
*
* Returns true only for:
* - "Is there anything else material that could change which option is better?"
* (the canonical product template)
* - Questions using the closure-family pattern:
* <remaining-uncertainty-phrase> + <closure-action-phrase>
* (e.g. "remaining material uncertainty preventing this decision from being closed")
*/
export function isDecisionClosureAuthorityQuestion(question) {
const text = String(question || "");
const lower = text.toLowerCase();
// Direct match for the canonical decision_threshold sufficiency confirmation template
if (/change which option (is |was )?better/i.test(lower)) return true;
// Closure-family pattern: "remaining-uncertainty" + "closure-action" conjunction
const hasRemainingUncertainty = /any(?:thing)?\s+(?:else\s+)?(?:material\s+)?uncertain(?:ty|ces)/i.test(lower);
if (!hasRemainingUncertainty) return false;
const hasClosureAction = /\b(closure|close[sd]?|resolve[d]?)\b/i.test(lower);
if (hasClosureAction) return true;
// "remaining material uncertainty" + decision-referencing words
const hasDecisionRef = /(?:decision|deciding|disposition)\b/i.test(lower);
if (hasDecisionRef) return true;
return false;
}
// ── Pure: unresolved predicate ─────────────────────────────────
/**
* Returns true when a node represents an unresolved unknown.
* Pure graph query — no mutation.
*/
function isUnresolvedUnknown(node) {
return (
node.kind === "unknown" && !TERMINAL_STATUSES.includes(node.status)
);
}
// ── Pure: remaining-factor helpers ──────────────────────────────
/**
* Returns true when any unresolved material factors remain for the decision.
*/
export function hasRemainingMaterialFactors(decisionNodeId, graph) {
return countRemainingMaterialFactors(decisionNodeId, graph) > 0;
}
/**
* 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
*
* @param {string} decisionNodeId
* @param {object} graph — SituationGraph with nodes/edges
* @param {Set<string>} [pendingResolvedIds] — optional set of node IDs that
* should be treated as already resolved (covers same-turn resolutions
* that the graph may not yet reflect). When omitted, only the actual
* graph status is used.
*/
export function countRemainingMaterialFactors(
decisionNodeId,
graph,
pendingResolvedIds,
) {
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;
}
function isUnresolved(candidateNode) {
if (pendingResolvedIds && pendingResolvedIds.has(candidateNode.id)) return false;
return isUnresolvedUnknown(candidateNode);
}
// 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 || !isUnresolved(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 && isUnresolved(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 && isUnresolved(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 || isUnresolved(source) === false || !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 || isUnresolved(fromNode) === false) 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;
}
// ── Pure: closure predicate ─────────────────────────────────────
/**
* Determines whether a decision node can be closed now.
*
* Equivalent to:
* countRemainingMaterialFactors(decisionNodeId, graph) === 0
* AND
* isUserConfirmationOfNoRemainingUncertainty(answer)
*
* @param {object} params
* @param {string} params.decisionNodeId
* @param {object} params.graph — SituationGraph (post-propagation state)
* @param {Set<string>} [params.pendingResolvedIds] — optional set of node IDs that
* should be treated as already resolved this turn. When omitted, only actual
* graph status is consulted.
* @param {string} params.answer
*/
export function shouldCloseDecision({
decisionNodeId,
graph,
answer,
pendingResolvedIds,
}) {
const remaining = countRemainingMaterialFactors(decisionNodeId, graph, pendingResolvedIds);
return remaining === 0 && isUserConfirmationOfNoRemainingUncertainty(answer);
}