fix(reasoning): reconcile evidence and preserve deterministic continuation

This commit is contained in:
2026-08-17 08:06:08 +01:00
parent f2f495d5c7
commit ade445e453
6 changed files with 1373 additions and 8 deletions
+600
View File
@@ -4504,6 +4504,191 @@ describe("60B.43 — terminal post-mutation target is cleared after valid decisi
};
}
function makeRichReconciliationFixture() {
const productLaunchDecision = makeNode({
id: "n_product_launch_decision_rich",
label: "Which option leaves us better off overall?",
description:
"Uncertainty about whether launching this year or waiting twelve months leaves the organisation better off overall.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const launchThisYear = makeNode({
id: "opt_launch_this_year_rich",
label: "Launch this year",
description:
"Launch this year with approximately £1.2M expected annual revenue and around £300,000 additional support and implementation cost.",
kind: "option",
status: "known",
confidence: "high",
});
const waitTwelveMonths = makeNode({
id: "opt_wait_twelve_months_rich",
label: "Wait twelve months",
description:
"Wait twelve months to reduce immediate support cost and improve the product, but delay revenue and risk competitor movement.",
kind: "option",
status: "known",
confidence: "high",
});
const customerSigningPrimary = makeNode({
id: "n_enterprise_customer_signing_rich",
label: "Prospective enterprise customer signing status",
description:
"Unknown whether the large enterprise customer will sign if we launch this year, because they account for approximately £700,000 of expected annual revenue.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const customerSigningSecondary = makeNode({
id: "n_customer_contract_timeline_pending",
label: "Enterprise customer contract status is currently unknown",
description:
"The status of the large enterprise customer's contract is currently unknown, including whether signing is still pending on this year's launch timeline.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const customerDecisionTimeline = makeNode({
id: "n_customer_decision_timeline",
label: "When will the enterprise customer make its decision?",
description:
"Unknown when the enterprise customer will make its decision, and the timing could still affect launch planning.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const revenueConsequence = makeNode({
id: "n_customer_revenue_consequence",
label: "Whether £700,000 of annual revenue from the enterprise customer will be received",
description:
"Unknown whether the £700,000 annual revenue linked to the enterprise customer will be received if we launch this year.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const commercialViability = makeNode({
id: "n_launch_viability_without_anchor_customer",
label: "Is launch commercially viable without the £700,000?",
description:
"Unknown whether launching this year is still commercially viable without the £700,000 enterprise-customer revenue.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const competitorRisk = makeNode({
id: "n_competitor_move_first_risk",
label: "Could competitors move first during a 12-month delay?",
description:
"Unknown whether competitors could move first if launch is delayed by twelve months, and this could still materially change the decision.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const graph = makeGraph({
centralStatement:
"We need to decide whether there is enough evidence to launch this new software product this year or whether waiting twelve months is safer.",
nodes: [
productLaunchDecision,
launchThisYear,
waitTwelveMonths,
customerSigningPrimary,
customerSigningSecondary,
customerDecisionTimeline,
revenueConsequence,
commercialViability,
competitorRisk,
],
edges: [
makeEdge({
id: "e-rich-launch-to-decision",
fromNodeId: launchThisYear.id,
toNodeId: productLaunchDecision.id,
relationship: "contained_in",
confidence: "high",
description: "Launch-this-year is an option in the decision.",
}),
makeEdge({
id: "e-rich-wait-to-decision",
fromNodeId: waitTwelveMonths.id,
toNodeId: productLaunchDecision.id,
relationship: "contained_in",
confidence: "high",
description: "Wait-twelve-months is an option in the decision.",
}),
makeEdge({
id: "e-rich-primary-customer-to-launch",
fromNodeId: customerSigningPrimary.id,
toNodeId: launchThisYear.id,
relationship: "contained_in",
confidence: "high",
description: "Primary customer-signing uncertainty is material to launching this year.",
}),
makeEdge({
id: "e-rich-secondary-customer-to-launch",
fromNodeId: customerSigningSecondary.id,
toNodeId: launchThisYear.id,
relationship: "contained_in",
confidence: "high",
description: "Secondary customer contract-status uncertainty is also material to launching this year.",
}),
makeEdge({
id: "e-rich-timeline-to-launch",
fromNodeId: customerDecisionTimeline.id,
toNodeId: launchThisYear.id,
relationship: "depends_on",
confidence: "medium",
description: "Launch planning depends in part on when the customer decides.",
}),
makeEdge({
id: "e-rich-revenue-consequence-to-launch",
fromNodeId: revenueConsequence.id,
toNodeId: launchThisYear.id,
relationship: "depends_on",
confidence: "high",
description: "Receiving the £700,000 revenue depends on whether the customer signs.",
}),
makeEdge({
id: "e-rich-viability-to-decision",
fromNodeId: commercialViability.id,
toNodeId: productLaunchDecision.id,
relationship: "depends_on",
confidence: "high",
description: "The decision depends on whether launch is viable without the anchor customer.",
}),
makeEdge({
id: "e-rich-competitor-risk-to-wait",
fromNodeId: competitorRisk.id,
toNodeId: waitTwelveMonths.id,
relationship: "contained_in",
confidence: "high",
description: "Competitor move-first risk is material to the wait option.",
}),
],
activeUnknownNodeId: productLaunchDecision.id,
resolvedNodeIds: [],
currentSummary:
"The decision is open; customer signing is unresolved; customer contract status and decision timing remain represented separately; revenue consequence, commercial viability, and competitor timing remain material uncertainties.",
});
return {
graph,
ids: {
productLaunchDecision: productLaunchDecision.id,
launchThisYear: launchThisYear.id,
waitTwelveMonths: waitTwelveMonths.id,
customerSigningPrimary: customerSigningPrimary.id,
customerSigningSecondary: customerSigningSecondary.id,
customerDecisionTimeline: customerDecisionTimeline.id,
revenueConsequence: revenueConsequence.id,
commercialViability: commercialViability.id,
competitorRisk: competitorRisk.id,
},
};
}
it("accepts the 60B.37-shaped closure proposal, resolves the customer factor, and clears the terminal decision target after mutation", () => {
const { graph, ids } = makeProductLaunchClosureFixture();
@@ -4617,6 +4802,421 @@ describe("60B.43 — terminal post-mutation target is cleared after valid decisi
expect(result.selectedQuestion?.nodeId).not.toBe(ids.productLaunchDecision);
});
it("60B.81 regression — after materially informative sufficiency answer, a specific remaining material factor should outrank repeated sufficiency confirmation", () => {
const { graph, ids } = makeProductLaunchClosureFixture({
includeFallbackUnknown: true,
});
const previousQuestion =
"Is there anything else material that could change which option is better?";
const answer =
"The enterprise customer has now confirmed in writing that they will not sign if we launch this year, so the £700,000 of expected annual revenue from them will not be received.";
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.enterpriseCustomerSigning,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: answer,
reason:
"The answer definitively resolves the enterprise-customer signing factor with materially new negative evidence.",
},
{
nodeId: ids.launchThisYear,
previousStatus: "known",
newStatus: "known",
previousValue: null,
newValue:
"If we launch this year, the £700,000 expected annual revenue from the enterprise customer will not be received.",
reason:
"Update the launch-this-year option to reflect the resolved financial consequence of the customer not signing.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.enterpriseCustomerSigning],
affectedNodeIds: [ids.launchThisYear, ids.productLaunchDecision],
selectedQuestion: {
nodeId: ids.productLaunchDecision,
question: previousQuestion,
reason:
"The prior turn asked the bounded sufficiency-confirmation question at the decision level.",
},
},
previousQuestion,
answer,
});
expect(result.success).toBe(true);
const resolvedCustomer = result.updatedSituationGraph.nodes.find(
(node) => node.id === ids.enterpriseCustomerSigning,
);
const remainingFactor = result.updatedSituationGraph.nodes.find(
(node) => node.id === ids.fallbackUnknown,
);
const launchOption = result.updatedSituationGraph.nodes.find(
(node) => node.id === ids.launchThisYear,
);
expect(resolvedCustomer?.status).toBe("resolved");
expect(remainingFactor?.status).toBe("unknown");
expect(result.updatedSituationGraph.resolvedNodeIds).toContain(
ids.enterpriseCustomerSigning,
);
expect(result.updatedSituationGraph.resolvedNodeIds).not.toContain(
ids.productLaunchDecision,
);
expect(result.newActiveUnknownNodeId).toBe(ids.fallbackUnknown);
expect(result.selectedQuestion?.nodeId).toBe(ids.fallbackUnknown);
expect(result.selectedQuestion?.selectedQuestionTemplate).not.toBe(
"decision_threshold_sufficiency_confirmation",
);
expect(result.selectedQuestion?.question).not.toBe(previousQuestion);
expect(launchOption?.value).toContain("£700,000");
expect(launchOption?.value).toContain("will not be received");
});
it("60B.82 richer live regression — definitive customer rejection must supersede stale overlapping customer-unknown state and must not repeat sufficiency question", () => {
const productLaunchDecision = makeNode({
id: "n_product_launch_decision_rich",
label: "Which option leaves us better off overall?",
description:
"Uncertainty about whether launching this year or waiting twelve months leaves the organisation better off overall.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const launchThisYear = makeNode({
id: "opt_launch_this_year_rich",
label: "Launch this year",
description:
"Launch this year with approximately £1.2M expected annual revenue and around £300,000 additional support and implementation cost.",
kind: "option",
status: "known",
confidence: "high",
});
const waitTwelveMonths = makeNode({
id: "opt_wait_twelve_months_rich",
label: "Wait twelve months",
description:
"Wait twelve months to reduce immediate support cost and improve the product, but delay revenue and risk competitor movement.",
kind: "option",
status: "known",
confidence: "high",
});
const customerSigningPrimary = makeNode({
id: "n_enterprise_customer_signing_rich",
label: "Prospective enterprise customer signing status",
description:
"Unknown whether the large enterprise customer will sign if we launch this year, because they account for approximately £700,000 of expected annual revenue.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const customerSigningSecondary = makeNode({
id: "n_customer_contract_timeline_pending",
label: "Enterprise customer contract timing still pending",
description:
"The status of the large enterprise customer's contract is currently unknown, including whether signing is still pending on this year's launch timeline.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const delayingBenefit = makeNode({
id: "n_delay_improvement_benefit",
label: "Quantifiable benefit of delaying launch for product improvements versus immediate market entry",
description:
"Unknown how much extra value twelve months of product improvement would create relative to launching now, and this could still change which option is better.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const competitorRisk = makeNode({
id: "n_competitor_move_first_risk",
label: "External firms that could move first if the product is delayed",
description:
"Unknown which external firms could move first if launch is delayed, and the competitive impact could still materially change the decision.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const graph = makeGraph({
centralStatement:
"We need to decide whether there is enough evidence to launch this new software product this year or whether waiting twelve months is safer.",
nodes: [
productLaunchDecision,
launchThisYear,
waitTwelveMonths,
customerSigningPrimary,
customerSigningSecondary,
delayingBenefit,
competitorRisk,
],
edges: [
makeEdge({
id: "e-rich-launch-to-decision",
fromNodeId: launchThisYear.id,
toNodeId: productLaunchDecision.id,
relationship: "contained_in",
confidence: "high",
description: "Launch-this-year is an option in the decision.",
}),
makeEdge({
id: "e-rich-wait-to-decision",
fromNodeId: waitTwelveMonths.id,
toNodeId: productLaunchDecision.id,
relationship: "contained_in",
confidence: "high",
description: "Wait-twelve-months is an option in the decision.",
}),
makeEdge({
id: "e-rich-primary-customer-to-launch",
fromNodeId: customerSigningPrimary.id,
toNodeId: launchThisYear.id,
relationship: "contained_in",
confidence: "high",
description: "Primary customer-signing uncertainty is material to launching this year.",
}),
makeEdge({
id: "e-rich-secondary-customer-to-launch",
fromNodeId: customerSigningSecondary.id,
toNodeId: launchThisYear.id,
relationship: "contained_in",
confidence: "high",
description: "Secondary customer contract-status uncertainty is also material to launching this year.",
}),
makeEdge({
id: "e-rich-delay-benefit-to-wait",
fromNodeId: delayingBenefit.id,
toNodeId: waitTwelveMonths.id,
relationship: "contained_in",
confidence: "high",
description: "The quantified benefit of delay is material to the wait option.",
}),
makeEdge({
id: "e-rich-competitor-risk-to-wait",
fromNodeId: competitorRisk.id,
toNodeId: waitTwelveMonths.id,
relationship: "contained_in",
confidence: "high",
description: "Competitor move-first risk is material to the wait option.",
}),
],
activeUnknownNodeId: productLaunchDecision.id,
resolvedNodeIds: [],
currentSummary:
"The decision is open; customer signing is unresolved; delaying-launch benefits and competitor timing remain material uncertainties.",
});
const previousQuestion =
"Is there anything else material that could change which option is better?";
const answer =
"The enterprise customer has now confirmed in writing that they will not sign if we launch this year, so the £700,000 of expected annual revenue from them will not be received.";
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: customerSigningPrimary.id,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: answer,
reason:
"The answer definitively resolves the primary enterprise-customer signing factor with negative evidence.",
},
{
nodeId: launchThisYear.id,
previousStatus: "known",
newStatus: "known",
previousValue: null,
newValue:
"If we launch this year, the £700,000 expected annual revenue from the enterprise customer will not be received.",
reason:
"Reflect the financial impact of the confirmed customer rejection in the launch-this-year option.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [customerSigningPrimary.id],
affectedNodeIds: [launchThisYear.id, productLaunchDecision.id],
selectedQuestion: {
nodeId: productLaunchDecision.id,
question: previousQuestion,
reason:
"The previous live turn was the sufficiency confirmation question at the decision level.",
},
},
previousQuestion,
answer,
});
expect(result.success).toBe(true);
const primaryCustomerNode = result.updatedSituationGraph.nodes.find(
(node) => node.id === customerSigningPrimary.id,
);
const staleCustomerNode = result.updatedSituationGraph.nodes.find(
(node) => node.id === customerSigningSecondary.id,
);
expect(primaryCustomerNode?.status).toBe("resolved");
expect(primaryCustomerNode?.value).toContain("will not sign");
expect(staleCustomerNode?.status).not.toBe("unknown");
expect(staleCustomerNode?.description?.toLowerCase()).not.toContain(
"currently unknown",
);
expect(staleCustomerNode?.description?.toLowerCase()).not.toContain(
"pending",
);
expect(result.selectedQuestion?.selectedQuestionTemplate).not.toBe(
"decision_threshold_sufficiency_confirmation",
);
expect(result.selectedQuestion?.question).not.toBe(previousQuestion);
expect([
delayingBenefit.id,
competitorRisk.id,
]).toContain(result.selectedQuestion?.nodeId);
});
describe("60B.83 — reconciliation safety envelope before generic implementation", () => {
function applyRichCustomerRejectionUpdate() {
const { graph, ids } = makeRichReconciliationFixture();
const previousQuestion =
"Is there anything else material that could change which option is better?";
const answer =
"The enterprise customer has now confirmed in writing that they will not sign if we launch this year, so the £700,000 of expected annual revenue from them will not be received.";
const result = applyValidatedProposal({
situationGraph: graph,
proposal: {
updatedNodes: [
{
nodeId: ids.customerSigningPrimary,
previousStatus: "unknown",
newStatus: "resolved",
previousValue: null,
newValue: answer,
reason:
"The answer definitively resolves the primary enterprise-customer signing factor with negative evidence.",
},
{
nodeId: ids.launchThisYear,
previousStatus: "known",
newStatus: "known",
previousValue: null,
newValue:
"If we launch this year, the £700,000 expected annual revenue from the enterprise customer will not be received.",
reason:
"Reflect the financial impact of the confirmed customer rejection in the launch-this-year option.",
},
],
addedNodes: [],
addedEdges: [],
removedEdgeIds: [],
resolvedUnknownNodeIds: [ids.customerSigningPrimary],
affectedNodeIds: [ids.launchThisYear, ids.productLaunchDecision],
selectedQuestion: {
nodeId: ids.productLaunchDecision,
question: previousQuestion,
reason:
"The previous live turn was the sufficiency confirmation question at the decision level.",
},
},
previousQuestion,
answer,
});
return { result, ids, previousQuestion, answer };
}
it("1 SAME_PROPOSITION — same-proposition sibling should no longer remain an active unresolved contradiction", () => {
const { result, ids } = applyRichCustomerRejectionUpdate();
expect(result.success).toBe(true);
const primaryNode = result.updatedSituationGraph.nodes.find(
(node) => node.id === ids.customerSigningPrimary,
);
const siblingNode = result.updatedSituationGraph.nodes.find(
(node) => node.id === ids.customerSigningSecondary,
);
expect(primaryNode?.status).toBe("resolved");
expect(siblingNode?.status).not.toBe("unknown");
});
it("2 SAME_ENTITY_DIFFERENT_DIMENSION — timeline node remains unresolved", () => {
const { result, ids } = applyRichCustomerRejectionUpdate();
expect(result.success).toBe(true);
const timelineNode = result.updatedSituationGraph.nodes.find(
(node) => node.id === ids.customerDecisionTimeline,
);
expect(timelineNode?.status).toBe("unknown");
});
it("3 DERIVED_CONSEQUENCE — structurally linked revenue consequence may be updated from explicit dependency semantics", () => {
const { result, ids } = applyRichCustomerRejectionUpdate();
expect(result.success).toBe(true);
const revenueNode = result.updatedSituationGraph.nodes.find(
(node) => node.id === ids.revenueConsequence,
);
expect(revenueNode?.status).not.toBe("unknown");
});
it("4 DOWNSTREAM_DEPENDENCY — commercial viability without the £700k remains unresolved", () => {
const { result, ids } = applyRichCustomerRejectionUpdate();
expect(result.success).toBe(true);
const viabilityNode = result.updatedSituationGraph.nodes.find(
(node) => node.id === ids.commercialViability,
);
expect(viabilityNode?.status).toBe("unknown");
});
it("5 UNRELATED — competitor factor remains unresolved and untouched", () => {
const { result, ids } = applyRichCustomerRejectionUpdate();
expect(result.success).toBe(true);
const competitorNode = result.updatedSituationGraph.nodes.find(
(node) => node.id === ids.competitorRisk,
);
expect(competitorNode?.status).toBe("unknown");
expect(competitorNode?.value ?? null).toBe(null);
});
it("6 QUESTION_CONTINUATION — next question should continue with a specific remaining factor, not repeated sufficiency confirmation", () => {
const { result, ids, previousQuestion } = applyRichCustomerRejectionUpdate();
expect(result.success).toBe(true);
expect([
ids.customerDecisionTimeline,
ids.commercialViability,
ids.competitorRisk,
]).toContain(result.selectedQuestion?.nodeId);
expect(result.selectedQuestion?.selectedQuestionTemplate).not.toBe(
"decision_threshold_sufficiency_confirmation",
);
expect(result.selectedQuestion?.question).not.toBe(previousQuestion);
});
});
it("returns no active target and no final question when only terminal unknowns remain after a valid mutation", () => {
const graph = makeGraph({
centralStatement: "Known-only terminal graph after mutation",
+416 -7
View File
@@ -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",
);
});
});
+86
View File
@@ -912,6 +912,92 @@ describe("applyGraphUpdate", () => {
});
});
describe("selectActiveUnknownCandidate deterministic tie resolution", () => {
it("returns a stable non-null winner when two eligible candidates tie on top score", () => {
const decision = makeNode({
id: "n_decision_tie",
label: "Which option leaves us better off overall?",
description: "Decision context",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const launch = makeNode({
id: "opt_launch_tie",
label: "Launch this year",
description: "Launch this year.",
kind: "option",
status: "known",
confidence: "high",
});
const timeline = makeNode({
id: "n_customer_decision_timeline",
label: "When will the enterprise customer make its decision?",
description:
"Unknown when the enterprise customer will make its decision, and the timing could still affect launch planning.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const viability = makeNode({
id: "n_launch_viability_without_anchor_customer",
label: "Is launch commercially viable without the £700,000?",
description:
"Unknown whether launching this year is still commercially viable without the £700,000 enterprise-customer revenue.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const competitor = makeNode({
id: "n_competitor_move_first_risk",
label: "Could competitors move first during a 12-month delay?",
description:
"Unknown whether competitors could move first if launch is delayed by twelve months.",
kind: "unknown",
status: "unknown",
confidence: "medium",
});
const graph = makeGraph({
centralStatement: "Launch decision",
nodes: [decision, launch, timeline, viability, competitor],
edges: [
makeEdge({
fromNodeId: launch.id,
toNodeId: decision.id,
relationship: "contained_in",
}),
makeEdge({
fromNodeId: timeline.id,
toNodeId: launch.id,
relationship: "depends_on",
}),
makeEdge({
fromNodeId: viability.id,
toNodeId: decision.id,
relationship: "depends_on",
}),
makeEdge({
fromNodeId: competitor.id,
toNodeId: launch.id,
relationship: "contained_in",
}),
],
activeUnknownNodeId: null,
resolvedNodeIds: [],
currentSummary: "Tie case",
});
const first = selectActiveUnknownCandidate(graph, []);
const second = selectActiveUnknownCandidate(graph, []);
expect(first?.status).toBe("selected");
expect(first?.nodeId).toBeTruthy();
expect(second?.status).toBe("selected");
expect(second?.nodeId).toBe(first?.nodeId);
});
});
describe("validateGraphUpdate", () => {
it("accepts a no-op update with added nodes", () => {
const graph = makeTestGraph();