diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js index 2b9ddf1..cf5b542 100644 --- a/lib/graph/apply-proposal.js +++ b/lib/graph/apply-proposal.js @@ -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; +} diff --git a/tests/graph/apply-proposal.test.js b/tests/graph/apply-proposal.test.js index 9d35007..9afd32c 100644 --- a/tests/graph/apply-proposal.test.js +++ b/tests/graph/apply-proposal.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js"; +import { applyValidatedProposal, hasRemainingMaterialFactors } from "@/lib/graph/apply-proposal.js"; import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; import { selectActiveUnknownCandidate, @@ -5008,3 +5008,357 @@ describe("60B.49 — reconciles updated-to-resolved unknown into resolvedUnknown } }); }); + +describe("60B.61 — decision remaining-material-factor detection", () => { + + // Test 1 — exact 60B.56 containment-only factor + it("returns true when unresolved unknown is contained_in an option of the decision", () => { + const decision = makeNode({ + id: "n_decision", + label: "Decision", + kind: "unknown", + status: "unknown", + }); + const optA = makeNode({ + id: "opt_a", + label: "Option A", + kind: "option", + status: "known", + }); + const factor = makeNode({ + id: "n_factor", + label: "Material factor", + kind: "unknown", + status: "unknown", + }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Decision context under test.", + nodes: [decision, optA, factor], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: factor.id, toNodeId: optA.id, relationship: "contained_in" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(true); + }); + + // Test 2 — same factor resolved + it("returns false when the containment-only factor is resolved", () => { + const decision = makeNode({ + id: "n_decision", + label: "Decision", + kind: "unknown", + status: "unknown", + }); + const optA = makeNode({ + id: "opt_a", + label: "Option A", + kind: "option", + status: "known", + }); + const factor = makeNode({ + id: "n_factor", + label: "Resolved factor", + kind: "unknown", + status: "resolved", + }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Decision context under test.", + nodes: [decision, optA, factor], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: factor.id, toNodeId: optA.id, relationship: "contained_in" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + }); + + // Test 3 — consequence-linked factor (Route C) + it("returns true when unresolved unknown has affects/may_cause/causes to an option of the decision", () => { + const decision = makeNode({ + id: "n_decision", + label: "Decision", + kind: "unknown", + status: "unknown", + }); + const optA = makeNode({ + id: "opt_a", + label: "Option A", + kind: "option", + status: "known", + }); + const factor = makeNode({ + id: "n_factor", + label: "Consequence factor", + kind: "unknown", + status: "unknown", + }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Decision context under test.", + nodes: [decision, optA, factor], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: factor.id, toNodeId: optA.id, relationship: "may_cause" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(true); + }); + + // Test 4 — direct dependency (Route B) + it("returns true when unresolved unknown directly depends_on the decision, and false once resolved", () => { + const decision = makeNode({ + id: "n_decision", + label: "Decision", + kind: "unknown", + status: "unknown", + }); + const depUnknown = makeNode({ + id: "n_dep", + label: "Dependency unknown", + kind: "unknown", + status: "unknown", + }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Decision context under test.", + nodes: [decision, depUnknown], + edges: [ + makeEdge({ fromNodeId: depUnknown.id, toNodeId: decision.id, relationship: "depends_on" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(true); + + // Once resolved + const resolvedGraph = makeGraph({ + centralStatement: "test", currentSummary: "Decision context under test.", + nodes: [ + { ...decision }, + { ...depUnknown, status: "resolved" }, + ], + edges: [ + makeEdge({ fromNodeId: depUnknown.id, toNodeId: decision.id, relationship: "depends_on" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, resolvedGraph)).toBe(false); + }); + + // Test 5 — hierarchy (Route A) + it("returns true when the decision has an unresolved child unknown via parentId", () => { + const childUnknown = makeNode({ + id: "n_child", + label: "Child unknown", + kind: "unknown", + status: "unknown", + }); + const decision = makeNode({ + id: "n_decision", + label: "Parent Decision", + kind: "unknown", + status: "unknown", + childIds: [childUnknown.id], + }); + childUnknown.parentId = decision.id; + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Decision context under test.", + nodes: [decision, childUnknown], + edges: [], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(true); + }); + + // Test 6 — weak relationship excluded + it("returns false when only supports/measures connects unresolved node to decision or option", () => { + const decision = makeNode({ + id: "n_decision", + label: "Decision", + kind: "unknown", + status: "unknown", + }); + const optA = makeNode({ + id: "opt_a", + label: "Option A", + kind: "option", + status: "known", + }); + const weakUnknown = makeNode({ + id: "n_weak", + label: "Weakly related unknown", + kind: "unknown", + status: "unknown", + }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Decision context under test.", + nodes: [decision, optA, weakUnknown], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: weakUnknown.id, toNodeId: optA.id, relationship: "supports" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + + // Also test measures + const graphMeasures = makeGraph({ + centralStatement: "test", currentSummary: "Decision context under test.", + nodes: [decision, optA, weakUnknown], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: weakUnknown.id, toNodeId: optA.id, relationship: "measures" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graphMeasures)).toBe(false); + }); + + // Test 7 — parent decision does not self-count + it("returns false when the decision node itself is kind=unknown status=unknown with no subordinate factors", () => { + const decision = makeNode({ + id: "n_decision_self", + label: "Decision itself is unknown", + kind: "unknown", + status: "unknown", + }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Decision context under test.", + nodes: [decision], + edges: [], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + }); + + // Test 8 — hierarchy child remains unresolved + it("returns true when a hierarchy child unknown stays unresolved", () => { + const childUnknown = makeNode({ + id: "n_child_factor", + label: "Child factor", + kind: "unknown", + status: "unknown", + }); + const decision = makeNode({ + id: "n_parent_dec", + label: "Parent decision", + kind: "unknown", + status: "unknown", + childIds: [childUnknown.id], + }); + childUnknown.parentId = decision.id; + + const graph = makeGraph({ + centralStatement: "hierarchy child remains", currentSummary: "Decision context under test.", + nodes: [decision, childUnknown], + edges: [], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(true); + }); + + // Test — status=known excluded (not terminal but also not unresolved) + it("returns false when unknown has status=known", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + const knownUnknown = makeNode({ id: "n_known", label: "Known unknown", kind: "unknown", status: "known" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Decision context under test.", + nodes: [decision, optA, knownUnknown], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: knownUnknown.id, toNodeId: optA.id, relationship: "contained_in" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + }); + + // Test — contradicted excluded + it("returns false when unknown has status=contradicted", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + const contradictedUnknown = makeNode({ id: "n_contra", label: "Contradicted unknown", kind: "unknown", status: "contradicted" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Decision context under test.", + nodes: [decision, optA, contradictedUnknown], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: contradictedUnknown.id, toNodeId: optA.id, relationship: "contained_in" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + }); + + // Test — non-unknown nodes excluded even if unresolved + it("returns false when only option-level unknowns of other kinds are present", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + const obsNode = makeNode({ id: "n_obs", label: "Observation", kind: "observation", status: "supported" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Decision context under test.", + nodes: [decision, optA, obsNode], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: obsNode.id, toNodeId: optA.id, relationship: "measures" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + }); + + // Test 60B.56 sufficiency regression: all factors resolved, decision still unknown + it("returns false after customer-signing factor is resolved (60B.56 sufficiency)", () => { + const decision = makeNode({ id: "n_decision", label: "Decision", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "Option A", kind: "option", status: "known" }); + const optB = makeNode({ id: "opt_b", label: "Option B", kind: "option", status: "known" }); + const resolvedFactor = makeNode({ id: "n_factor", label: "Resolved factor", kind: "unknown", status: "resolved" }); + + const graph = makeGraph({ + centralStatement: "60B.56 sufficiency: no unresolved factors remain", currentSummary: "Decision context under test.", + nodes: [decision, optA, optB, resolvedFactor], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: optB.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: resolvedFactor.id, toNodeId: optA.id, relationship: "contained_in" }), + ], + activeUnknownNodeId: null, + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + }); + + // Test — arbitrary connectivity excluded + it("returns false when unknown is only connected via non-approved edges to unrelated nodes", () => { + const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" }); + const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" }); + const farUnknown = makeNode({ id: "n_far", label: "Far unknown", kind: "unknown", status: "unknown" }); + const bridgeOpt = makeNode({ id: "opt_bridge", label: "Bridge opt", kind: "option", status: "known" }); + + const graph = makeGraph({ + centralStatement: "test", currentSummary: "Decision context under test.", + nodes: [decision, optA, farUnknown, bridgeOpt], + edges: [ + makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }), + makeEdge({ fromNodeId: bridgeOpt.id, toNodeId: decision.id, relationship: "contained_in" }), + // farUnknown connected only via 'other' edge — not an approved route + makeEdge({ fromNodeId: farUnknown.id, toNodeId: bridgeOpt.id, relationship: "other" }), + ], + }); + + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + }); +}); +