feat(reasoning): detect remaining decision factors

This commit is contained in:
2026-08-14 13:31:28 +01:00
parent 909edd1019
commit 5ef2b5a3c7
2 changed files with 472 additions and 1 deletions
+117
View File
@@ -4401,3 +4401,120 @@ export function applyValidatedProposal({
reasoningState: nextReasoningState,
};
}
// ── 60B.61 — decision remaining-material-factor detection ──
const TERMINAL_STATUSES = ["known", "resolved", "contradicted"];
function isUnresolvedUnknown(node) {
return (
node.kind === "unknown" && !TERMINAL_STATUSES.includes(node.status)
);
}
export function hasRemainingMaterialFactors(decisionNodeId, graph) {
return countRemainingMaterialFactors(decisionNodeId, graph) > 0;
}
// ── 60B.61 — count remaining material factors for a decision node ──
/**
* 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
*/
export function countRemainingMaterialFactors(decisionNodeId, graph) {
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;
}
// 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 || !isUnresolvedUnknown(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 && isUnresolvedUnknown(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 && isUnresolvedUnknown(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 || !isUnresolvedUnknown(source) || !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 || !isUnresolvedUnknown(fromNode)) 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;
}