feat(reasoning): detect remaining decision factors
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user