234 lines
8.0 KiB
JavaScript
234 lines
8.0 KiB
JavaScript
// ── 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: 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);
|
||
}
|
||
|