fix(reasoning): reconcile evidence and preserve deterministic continuation
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
} from "@/lib/graph/question-formulator.js";
|
||||
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
|
||||
import {
|
||||
countRemainingMaterialFactors,
|
||||
hasRemainingMaterialFactors,
|
||||
isUserConfirmationOfNoRemainingUncertainty,
|
||||
} from "@/lib/graph/decision-sufficiency.js";
|
||||
@@ -1110,10 +1111,24 @@ describe("formulateQuestion", () => {
|
||||
confidence: "medium",
|
||||
childIds: [],
|
||||
});
|
||||
const option = makeNode({
|
||||
id: "opt_launch_product_x",
|
||||
label: "Launch product X",
|
||||
description: "Launch product X now.",
|
||||
kind: "option",
|
||||
status: "known",
|
||||
confidence: "high",
|
||||
});
|
||||
|
||||
const graph = makeGraphFor(decision, {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
nodes: [option],
|
||||
edges: [
|
||||
makeEdge({
|
||||
fromNodeId: option.id,
|
||||
toNodeId: decision.id,
|
||||
relationship: "contained_in",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const result = formulateQuestion({
|
||||
@@ -1141,8 +1156,25 @@ describe("formulateQuestion", () => {
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
const option = makeNode({
|
||||
id: "opt_launch_product_x_2",
|
||||
label: "Launch product X",
|
||||
description: "Launch product X now.",
|
||||
kind: "option",
|
||||
status: "known",
|
||||
confidence: "high",
|
||||
});
|
||||
|
||||
const graph = makeGraphFor(decision, { nodes: [], edges: [] });
|
||||
const graph = makeGraphFor(decision, {
|
||||
nodes: [option],
|
||||
edges: [
|
||||
makeEdge({
|
||||
fromNodeId: option.id,
|
||||
toNodeId: decision.id,
|
||||
relationship: "contained_in",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const result = formulateQuestion({
|
||||
node: decision,
|
||||
@@ -1299,10 +1331,24 @@ describe("formulateQuestion", () => {
|
||||
confidence: "medium",
|
||||
childIds: [factor.id],
|
||||
});
|
||||
const option = makeNode({
|
||||
id: "opt_launch_product_x_7",
|
||||
label: "Launch product X",
|
||||
description: "Launch product X now.",
|
||||
kind: "option",
|
||||
status: "known",
|
||||
confidence: "high",
|
||||
});
|
||||
|
||||
const graph = makeGraphFor(factor, {
|
||||
nodes: [decision],
|
||||
edges: [],
|
||||
const graph = makeGraphFor(decision, {
|
||||
nodes: [factor, option],
|
||||
edges: [
|
||||
makeEdge({
|
||||
fromNodeId: option.id,
|
||||
toNodeId: decision.id,
|
||||
relationship: "contained_in",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Verified: zero remaining factors (factor is resolved)
|
||||
@@ -1329,8 +1375,25 @@ describe("formulateQuestion", () => {
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
const option = makeNode({
|
||||
id: "opt_launch_product_x_8",
|
||||
label: "Launch product X",
|
||||
description: "Launch product X now.",
|
||||
kind: "option",
|
||||
status: "known",
|
||||
confidence: "high",
|
||||
});
|
||||
|
||||
const graph = makeGraphFor(decision, { nodes: [], edges: [] });
|
||||
const graph = makeGraphFor(decision, {
|
||||
nodes: [option],
|
||||
edges: [
|
||||
makeEdge({
|
||||
fromNodeId: option.id,
|
||||
toNodeId: decision.id,
|
||||
relationship: "contained_in",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const result = formulateQuestion({
|
||||
node: decision,
|
||||
@@ -1550,3 +1613,349 @@ describe("60B.75 — real decision detection for sufficiency question", () => {
|
||||
expect(result.question).toContain("anything else material");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 60B.80 — sufficiency re-selection after proposal application (no false State B) ────
|
||||
|
||||
describe("60B.80 — no false State B after proposal adds unresolved material factor", () => {
|
||||
it("Test 1 — new unresolved unknown with parentId correctly counted as remaining factor", () => {
|
||||
// Start: decision with NO edges → hasRemainingMaterialFactors = 0 → State B triggers
|
||||
const decision = makeNode({
|
||||
id: "n_launch_decision",
|
||||
label: "Whether to launch product X",
|
||||
description: "Launch vs not launch decision for product X.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
const option = makeNode({
|
||||
id: "opt_launch_decision_80_1",
|
||||
label: "Launch product X",
|
||||
description: "Launch product X now.",
|
||||
kind: "option",
|
||||
status: "known",
|
||||
confidence: "high",
|
||||
});
|
||||
|
||||
const graph = makeGraphFor(decision, {
|
||||
nodes: [option],
|
||||
edges: [
|
||||
makeEdge({
|
||||
fromNodeId: option.id,
|
||||
toNodeId: decision.id,
|
||||
relationship: "contained_in",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false);
|
||||
|
||||
// State B correctly triggers here (no remaining factors)
|
||||
const initialResult = formulateQuestion({
|
||||
node: decision,
|
||||
graph,
|
||||
context: { resolvedValues: [] },
|
||||
});
|
||||
|
||||
expect(initialResult.selectedQuestionTemplate).toBe(
|
||||
"decision_threshold_sufficiency_confirmation",
|
||||
);
|
||||
|
||||
// Now simulate what happens AFTER a proposal adds a NEW unresolved unknown
|
||||
// The new unknown represents customer signing uncertainty
|
||||
const newUnknown = makeNode({
|
||||
id: "n_customer_signing",
|
||||
label: "Customer signing commitment timeline",
|
||||
description: "Uncertainty about whether the key customer will sign.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
parentId: "n_launch_decision",
|
||||
});
|
||||
|
||||
// Update decision to include new unknown in childIds and add it to graph
|
||||
const updatedDecision = { ...decision, childIds: [newUnknown.id] };
|
||||
const updatedGraph = makeGraph({
|
||||
centralStatement: "Launch decision context",
|
||||
nodes: [updatedDecision, newUnknown],
|
||||
edges: [],
|
||||
activeUnknownNodeId: decision.id,
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: "Updated summary",
|
||||
});
|
||||
|
||||
// After the proposal adds a material factor, count should be 1
|
||||
expect(countRemainingMaterialFactors(updatedDecision.id, updatedGraph)).toBe(1);
|
||||
});
|
||||
|
||||
it("Test 2 — formulated question does NOT re-trigger State B when new unresolved factor exists", () => {
|
||||
// Setup: decision + new unresolved unknown with proper parentId link
|
||||
const decision = makeNode({
|
||||
id: "n_launch_decision",
|
||||
label: "Whether to launch product X",
|
||||
description: "Launch vs not launch.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
childIds: ["n_customer_signing"],
|
||||
});
|
||||
|
||||
const customerSigning = makeNode({
|
||||
id: "n_customer_signing",
|
||||
label: "Customer signing commitment timeline",
|
||||
description: "Uncertainty about whether the key customer will sign.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
parentId: "n_launch_decision",
|
||||
});
|
||||
|
||||
const graph = makeGraph({
|
||||
centralStatement: "Launch decision context",
|
||||
nodes: [decision, customerSigning],
|
||||
edges: [],
|
||||
activeUnknownNodeId: decision.id,
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: "Updated summary",
|
||||
});
|
||||
|
||||
// Core invariant: countRemainingMaterialFactors must detect the new factor
|
||||
expect(countRemainingMaterialFactors(decision.id, graph)).toBe(1);
|
||||
|
||||
// When we formulate a question for the decision (e.g., after proposal application),
|
||||
// State B should NOT trigger because remaining factors exist.
|
||||
const result = formulateQuestion({
|
||||
node: decision,
|
||||
graph,
|
||||
context: { resolvedValues: [] },
|
||||
});
|
||||
|
||||
// The key assertion: template must NOT be sufficiency_confirmation
|
||||
// When there ARE remaining material factors, the engine should produce
|
||||
// a normal decision_threshold question (seeking the missing factor), not
|
||||
// the sufficiency confirmation that State B would wrongly provide.
|
||||
expect(result.selectedQuestionTemplate).not.toBe(
|
||||
"decision_threshold_sufficiency_confirmation",
|
||||
);
|
||||
});
|
||||
|
||||
it("Test 3 — customer signing resolution preserves new remaining factors correctly", () => {
|
||||
// Setup: two unknowns, both unresolved, decision in State B (would trigger)
|
||||
const decision = makeNode({
|
||||
id: "n_launch_decision",
|
||||
label: "Whether to launch product X",
|
||||
description: "Launch vs not launch.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
childIds: ["n_customer_signing"],
|
||||
});
|
||||
|
||||
const customerSigning = makeNode({
|
||||
id: "n_customer_signing",
|
||||
label: "Customer signing commitment timeline",
|
||||
description: "Uncertainty about whether the key customer will sign.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
parentId: "n_launch_decision",
|
||||
});
|
||||
|
||||
const graph = makeGraph({
|
||||
centralStatement: "Launch decision context",
|
||||
nodes: [decision, customerSigning],
|
||||
edges: [],
|
||||
activeUnknownNodeId: decision.id,
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: "Updated summary",
|
||||
});
|
||||
|
||||
// customer signing remains unresolved — count = 1 → no State B
|
||||
expect(countRemainingMaterialFactors(decision.id, graph)).toBe(1);
|
||||
|
||||
const result = formulateQuestion({
|
||||
node: decision,
|
||||
graph,
|
||||
context: { resolvedValues: [] },
|
||||
});
|
||||
|
||||
// Should NOT produce sufficiency confirmation because customer signing remains unresolved
|
||||
expect(result.selectedQuestionTemplate).not.toBe(
|
||||
"decision_threshold_sufficiency_confirmation",
|
||||
);
|
||||
});
|
||||
|
||||
it("Test 4 — after resolving ONE factor, State B correctly re-triggers only when ALL factors resolved", () => {
|
||||
const decision = makeNode({
|
||||
id: "n_launch_decision",
|
||||
label: "Whether to launch product X",
|
||||
description: "Launch vs not launch.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
childIds: ["n_customer_signing"],
|
||||
});
|
||||
|
||||
// customerSigning starts unresolved, then gets resolved (status changes)
|
||||
const customerSigning = makeNode({
|
||||
id: "n_customer_signing",
|
||||
label: "Customer signing commitment timeline",
|
||||
description: "Uncertainty about whether the key customer will sign.",
|
||||
kind: "unknown",
|
||||
status: "resolved", // resolved via node.status (State B checks this)
|
||||
confidence: "high",
|
||||
parentId: "n_launch_decision",
|
||||
});
|
||||
|
||||
const graph = makeGraph({
|
||||
centralStatement: "Launch decision context",
|
||||
nodes: [
|
||||
decision,
|
||||
customerSigning,
|
||||
makeNode({
|
||||
id: "opt_launch_decision_80_4",
|
||||
label: "Launch product X",
|
||||
description: "Launch product X now.",
|
||||
kind: "option",
|
||||
status: "known",
|
||||
confidence: "high",
|
||||
}),
|
||||
],
|
||||
edges: [
|
||||
makeEdge({
|
||||
fromNodeId: "opt_launch_decision_80_4",
|
||||
toNodeId: decision.id,
|
||||
relationship: "contained_in",
|
||||
}),
|
||||
],
|
||||
activeUnknownNodeId: decision.id,
|
||||
resolvedNodeIds: ["n_customer_signing"],
|
||||
currentSummary: "Updated summary",
|
||||
});
|
||||
|
||||
// After resolution (status = "resolved"): hasRemainingMaterialFactors = 0 → State B SHOULD trigger
|
||||
expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false);
|
||||
|
||||
const result = formulateQuestion({
|
||||
node: decision,
|
||||
graph,
|
||||
context: { resolvedValues: [] },
|
||||
});
|
||||
|
||||
// Now State B should correctly trigger because all factors are resolved
|
||||
expect(result.selectedQuestionTemplate).toBe(
|
||||
"decision_threshold_sufficiency_confirmation",
|
||||
);
|
||||
});
|
||||
|
||||
it("Test 5 — explicit confirmation prevents State B even with zero remaining factors", () => {
|
||||
const decision = makeNode({
|
||||
id: "n_launch_decision",
|
||||
label: "Whether to launch product X",
|
||||
description: "Launch vs not launch.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
|
||||
const graph = makeGraphFor(decision, { nodes: [], edges: [] });
|
||||
|
||||
expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false);
|
||||
|
||||
// User confirms sufficiency during State B question
|
||||
const result = formulateQuestion({
|
||||
node: decision,
|
||||
graph,
|
||||
context: {
|
||||
resolvedValues: [
|
||||
"no other material uncertainty remains",
|
||||
"Customer signing is the last factor and they will sign.",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Should NOT be sufficiency confirmation because user already confirmed
|
||||
expect(result.selectedQuestionTemplate).not.toBe(
|
||||
"decision_threshold_sufficiency_confirmation",
|
||||
);
|
||||
});
|
||||
|
||||
it("60B.84 — State B must not fire for a specific factor node merely because it is inside a decision context", () => {
|
||||
const decision = makeNode({
|
||||
id: "n_launch_decision_factor_guard",
|
||||
label: "Which option leaves us better off overall?",
|
||||
description: "Decision about whether to launch this year or wait.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
|
||||
const launchOption = makeNode({
|
||||
id: "opt_launch_factor_guard",
|
||||
label: "Launch this year",
|
||||
description: "Launch this year.",
|
||||
kind: "option",
|
||||
status: "known",
|
||||
confidence: "high",
|
||||
});
|
||||
|
||||
const selectedFactor = makeNode({
|
||||
id: "n_specific_remaining_factor",
|
||||
label: "Other market evidence gap",
|
||||
description:
|
||||
"Need other market evidence because the remaining launch case still depends on it.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "high",
|
||||
});
|
||||
|
||||
const additionalFactor = makeNode({
|
||||
id: "n_additional_remaining_factor",
|
||||
label: "Competitor response uncertainty",
|
||||
description:
|
||||
"Unknown whether competitors would move first if launch is delayed.",
|
||||
kind: "unknown",
|
||||
status: "unknown",
|
||||
confidence: "medium",
|
||||
});
|
||||
|
||||
const graph = makeGraph({
|
||||
centralStatement:
|
||||
"We need to decide whether to launch this year or wait twelve months.",
|
||||
nodes: [decision, launchOption, selectedFactor, additionalFactor],
|
||||
edges: [
|
||||
makeEdge({
|
||||
fromNodeId: launchOption.id,
|
||||
toNodeId: decision.id,
|
||||
relationship: "contained_in",
|
||||
}),
|
||||
makeEdge({
|
||||
fromNodeId: selectedFactor.id,
|
||||
toNodeId: launchOption.id,
|
||||
relationship: "may_cause",
|
||||
}),
|
||||
makeEdge({
|
||||
fromNodeId: additionalFactor.id,
|
||||
toNodeId: launchOption.id,
|
||||
relationship: "contained_in",
|
||||
}),
|
||||
],
|
||||
activeUnknownNodeId: selectedFactor.id,
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: "Specific unresolved factors remain.",
|
||||
});
|
||||
|
||||
const pattern = selectReasoningPattern({ node: selectedFactor, graph });
|
||||
expect(pattern.pattern).toBe("decision");
|
||||
|
||||
const result = formulateQuestion({
|
||||
node: selectedFactor,
|
||||
graph,
|
||||
context: { resolvedValues: [] },
|
||||
});
|
||||
|
||||
expect(selectedFactor.id).not.toBe(decision.id);
|
||||
expect(result.selectedQuestionTemplate).not.toBe(
|
||||
"decision_threshold_sufficiency_confirmation",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user