Feature/product platform foundation v0.62 #1

Merged
robbond merged 683 commits from feature/product-platform-foundation-v0.62 into feature/emergent-unknowns-v0.5 2026-09-09 07:58:20 +01:00
2 changed files with 472 additions and 1 deletions
Showing only changes of commit 5ef2b5a3c7 - Show all commits
+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;
}
+355 -1
View File
@@ -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);
});
});