457 lines
18 KiB
JavaScript
457 lines
18 KiB
JavaScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
hasRemainingMaterialFactors,
|
|
shouldCloseDecision,
|
|
isUserConfirmationOfNoRemainingUncertainty,
|
|
} from "@/lib/graph/decision-sufficiency.js";
|
|
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
|
|
|
// ── Confirmation detection tests ──────────────────────────────
|
|
|
|
describe("isUserConfirmationOfNoRemainingUncertainty", () => {
|
|
|
|
it("returns true for exact confirmation phrase: no other material uncertainty remains", () => {
|
|
expect(isUserConfirmationOfNoRemainingUncertainty(
|
|
"There are no other material uncertainties between launching this year and waiting twelve months.",
|
|
)).toBe(true);
|
|
});
|
|
|
|
it("returns true for bounded paraphrase: no remaining material uncertainty", () => {
|
|
expect(isUserConfirmationOfNoRemainingUncertainty(
|
|
"no remaining material uncertainty exists between the options.",
|
|
)).toBe(true);
|
|
});
|
|
|
|
it("returns true for plural variant: no other material uncertainties remain", () => {
|
|
expect(isUserConfirmationOfNoRemainingUncertainty(
|
|
"There are no other material uncertainties between the options.",
|
|
)).toBe(true);
|
|
});
|
|
|
|
it("returns true for 'no further material uncertainty remains' (exact phrase via includes)", () => {
|
|
expect(isUserConfirmationOfNoRemainingUncertainty(
|
|
"I confirm: no further material uncertainty remains.",
|
|
)).toBe(true);
|
|
});
|
|
|
|
it("rejects contradictory wording: 'still ... material'", () => {
|
|
expect(isUserConfirmationOfNoRemainingUncertainty(
|
|
"There is still another material uncertainty.",
|
|
)).toBe(false);
|
|
});
|
|
|
|
it("rejects contradictory wording: 'am not saying'", () => {
|
|
expect(isUserConfirmationOfNoRemainingUncertainty(
|
|
"I am not saying there are no other material uncertainties.",
|
|
)).toBe(false);
|
|
});
|
|
|
|
it("rejects negated phrase: 'not claiming'", () => {
|
|
expect(isUserConfirmationOfNoRemainingUncertainty(
|
|
"I'm not claiming that all uncertainties are resolved.",
|
|
)).toBe(false);
|
|
});
|
|
|
|
it("rejects vague completion phrases like 'That's it'", () => {
|
|
expect(isUserConfirmationOfNoRemainingUncertainty("That's it.")).toBe(false);
|
|
});
|
|
|
|
it("returns false for non-confirmation answer", () => {
|
|
expect(isUserConfirmationOfNoRemainingUncertainty(
|
|
"The customer has confirmed they will sign.",
|
|
)).toBe(false);
|
|
});
|
|
|
|
it("returns false for null input", () => {
|
|
expect(isUserConfirmationOfNoRemainingUncertainty(null)).toBe(false);
|
|
});
|
|
|
|
it("returns false for empty string", () => {
|
|
expect(isUserConfirmationOfNoRemainingUncertainty("")).toBe(false);
|
|
});
|
|
|
|
it("returns false for non-string input", () => {
|
|
expect(isUserConfirmationOfNoRemainingUncertainty(42)).toBe(false);
|
|
});
|
|
|
|
it("accepts 'no remaining material differences'", () => {
|
|
expect(isUserConfirmationOfNoRemainingUncertainty(
|
|
"There are no remaining material differences between the options.",
|
|
)).toBe(true);
|
|
});
|
|
|
|
it("accepts 'nothing else material is uncertain'", () => {
|
|
expect(isUserConfirmationOfNoRemainingUncertainty(
|
|
"Nothing else material is uncertain about this decision.",
|
|
)).toBe(true);
|
|
});
|
|
|
|
it("accepts bounded regex pattern: 'no other/further material ... between'", () => {
|
|
expect(isUserConfirmationOfNoRemainingUncertainty(
|
|
"No further material difference between the choices.",
|
|
)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── Remaining-factor detection tests ──────────────────────────
|
|
|
|
describe("hasRemainingMaterialFactors", () => {
|
|
|
|
// Test 1 — containment-only factor (Route D)
|
|
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.",
|
|
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_dec", label: "D", kind: "unknown", status: "unknown" });
|
|
const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" });
|
|
const factor = makeNode({ id: "n_factor", label: "R", kind: "unknown", status: "resolved" });
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "test", currentSummary: "Context.",
|
|
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", () => {
|
|
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 factor = makeNode({ id: "n_factor", label: "F", kind: "unknown", status: "unknown" });
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "test", currentSummary: "Context.",
|
|
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", () => {
|
|
const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" });
|
|
const depUnknown = makeNode({ id: "n_dep", label: "Dep", kind: "unknown", status: "unknown" });
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "test", currentSummary: "Context.",
|
|
nodes: [decision, depUnknown],
|
|
edges: [
|
|
makeEdge({ fromNodeId: depUnknown.id, toNodeId: decision.id, relationship: "depends_on" }),
|
|
],
|
|
});
|
|
|
|
expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(true);
|
|
});
|
|
|
|
// Test 5 — hierarchy (Route A) childIds
|
|
it("returns true when the decision has an unresolved child unknown via childIds", () => {
|
|
const childUnknown = makeNode({ id: "n_child", label: "Child", kind: "unknown", status: "unknown" });
|
|
const decision = makeNode({
|
|
id: "n_decision", label: "D", kind: "unknown", status: "unknown",
|
|
childIds: [childUnknown.id],
|
|
});
|
|
childUnknown.parentId = decision.id;
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "test", currentSummary: "Context.",
|
|
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", () => {
|
|
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 weakUnknown = makeNode({ id: "n_weak", label: "W", kind: "unknown", status: "unknown" });
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "test", currentSummary: "Context.",
|
|
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);
|
|
});
|
|
|
|
// Test 7 — decision node itself doesn't self-count
|
|
it("returns false when the decision node itself is unknown with no factors", () => {
|
|
const decision = makeNode({ id: "n_dec_self", label: "D", kind: "unknown", status: "unknown" });
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "test", currentSummary: "Context.",
|
|
nodes: [decision],
|
|
edges: [],
|
|
});
|
|
|
|
expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false);
|
|
});
|
|
|
|
// Test 8 — status=known excluded
|
|
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: "K", kind: "unknown", status: "known" });
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "test", currentSummary: "Context.",
|
|
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 9 — 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 contra = makeNode({ id: "n_contra", label: "C", kind: "unknown", status: "contradicted" });
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "test", currentSummary: "Context.",
|
|
nodes: [decision, optA, contra],
|
|
edges: [
|
|
makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }),
|
|
makeEdge({ fromNodeId: contra.id, toNodeId: optA.id, relationship: "contained_in" }),
|
|
],
|
|
});
|
|
|
|
expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false);
|
|
});
|
|
|
|
// Test 10 — non-unknown nodes excluded
|
|
it("returns false when only observation-level unknowns 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: "Obs", kind: "observation", status: "supported" });
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "test", currentSummary: "Context.",
|
|
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 — arbitrary connectivity excluded
|
|
it("returns false when unknown connected only via non-approved edges", () => {
|
|
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: "F", kind: "unknown", status: "unknown" });
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "test", currentSummary: "Context.",
|
|
nodes: [decision, optA, farUnknown],
|
|
edges: [
|
|
makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }),
|
|
makeEdge({ fromNodeId: farUnknown.id, toNodeId: optA.id, relationship: "other" }),
|
|
],
|
|
});
|
|
|
|
expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── shouldCloseDecision predicate tests ───────────────────────
|
|
|
|
describe("shouldCloseDecision", () => {
|
|
|
|
function makeClosureGraph({ extraFactor = null } = {}) {
|
|
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 nodes = [decision, optA];
|
|
const edges = [
|
|
makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }),
|
|
];
|
|
|
|
if (extraFactor) {
|
|
nodes.push(extraFactor);
|
|
}
|
|
|
|
return { graph: makeGraph({ centralStatement: "test", currentSummary: "C.", nodes, edges }) };
|
|
}
|
|
|
|
it("returns true when no remaining factors AND explicit confirmation 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 graph = makeGraph({
|
|
centralStatement: "test", currentSummary: "C.",
|
|
nodes: [decision, optA],
|
|
edges: [makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" })],
|
|
});
|
|
|
|
expect(shouldCloseDecision({
|
|
decisionNodeId: decision.id,
|
|
graph,
|
|
answer: "no other material uncertainty remains",
|
|
})).toBe(true);
|
|
});
|
|
|
|
it("returns false when no remaining factors BUT no confirmation", () => {
|
|
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 graph = makeGraph({
|
|
centralStatement: "test", currentSummary: "C.",
|
|
nodes: [decision, optA],
|
|
edges: [makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" })],
|
|
});
|
|
|
|
expect(shouldCloseDecision({
|
|
decisionNodeId: decision.id,
|
|
graph,
|
|
answer: "The customer signed.",
|
|
})).toBe(false);
|
|
});
|
|
|
|
it("returns false when remaining factors 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 remainingFactor = makeNode({ id: "n_rem", label: "R", kind: "unknown", status: "unknown" });
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "test", currentSummary: "C.",
|
|
nodes: [decision, optA, remainingFactor],
|
|
edges: [
|
|
makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }),
|
|
makeEdge({ fromNodeId: remainingFactor.id, toNodeId: optA.id, relationship: "may_cause" }),
|
|
],
|
|
});
|
|
|
|
expect(shouldCloseDecision({
|
|
decisionNodeId: decision.id,
|
|
graph,
|
|
answer: "no other material uncertainty remains",
|
|
})).toBe(false);
|
|
});
|
|
|
|
it("returns false when confirmation is contradictory", () => {
|
|
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 graph = makeGraph({
|
|
centralStatement: "test", currentSummary: "C.",
|
|
nodes: [decision, optA],
|
|
edges: [makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" })],
|
|
});
|
|
|
|
expect(shouldCloseDecision({
|
|
decisionNodeId: decision.id,
|
|
graph,
|
|
answer: "There is still another material uncertainty.",
|
|
})).toBe(false);
|
|
});
|
|
|
|
it("uses pendingResolvedIds to treat a node as resolved", () => {
|
|
const decision = makeNode({ id: "n_dec", label: "D", kind: "unknown", status: "unknown" });
|
|
const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" });
|
|
// factor still shows as unknown (not yet synced)
|
|
const unresolvedFactor = makeNode({ id: "n_factor", label: "F", kind: "unknown", status: "unknown" });
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "test", currentSummary: "C.",
|
|
nodes: [decision, optA, unresolvedFactor],
|
|
edges: [
|
|
makeEdge({ fromNodeId: optA.id, toNodeId: decision.id, relationship: "contained_in" }),
|
|
makeEdge({ fromNodeId: unresolvedFactor.id, toNodeId: optA.id, relationship: "contained_in" }),
|
|
],
|
|
});
|
|
|
|
// Without pendingResolvedIds → remaining > 0 → false
|
|
expect(shouldCloseDecision({
|
|
decisionNodeId: decision.id,
|
|
graph,
|
|
answer: "no other material uncertainty remains",
|
|
})).toBe(false);
|
|
|
|
// With pendingResolvedIds including the factor → remaining = 0 + confirmed → true
|
|
const pendingResolvedIds = new Set(["n_factor"]);
|
|
expect(shouldCloseDecision({
|
|
decisionNodeId: decision.id,
|
|
graph,
|
|
answer: "no other material uncertainty remains",
|
|
pendingResolvedIds,
|
|
})).toBe(true);
|
|
});
|
|
|
|
it("non-existent decision node with confirmation returns true (remaining = 0)", () => {
|
|
const optA = makeNode({ id: "opt_a", label: "O", kind: "option", status: "known" });
|
|
|
|
const graph = makeGraph({
|
|
centralStatement: "test", currentSummary: "C.",
|
|
nodes: [optA],
|
|
edges: [],
|
|
});
|
|
|
|
// countRemainingMaterialFactors returns 0 for non-existent node
|
|
// so combined with confirmation, shouldCloseDecision returns true
|
|
// (this matches original checkRemainingFactorsVirtual semantics which also returns 0)
|
|
expect(shouldCloseDecision({
|
|
decisionNodeId: "n_nonexistent",
|
|
graph,
|
|
answer: "no other material uncertainty remains",
|
|
})).toBe(true);
|
|
|
|
// Without confirmation — still false
|
|
expect(shouldCloseDecision({
|
|
decisionNodeId: "n_nonexistent",
|
|
graph,
|
|
answer: "Just confirming the customer signed.",
|
|
})).toBe(false);
|
|
});
|
|
});
|