fix(reasoning): reconcile evidence and preserve deterministic continuation
This commit is contained in:
+249
-1
@@ -610,6 +610,148 @@ function buildAffectedNodeIds(graph, proposal) {
|
||||
return [...affected];
|
||||
}
|
||||
|
||||
function normaliseReconciliationSemanticText(value) {
|
||||
return normaliseText(String(value || "")).replace(/\b(the|a|an|its|this|that)\b/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function collectLocalReconciliationCandidateIds(graph, anchorNodeId) {
|
||||
const candidateIds = new Set();
|
||||
const anchorDecisionId = findContainingDecisionNodeId(graph, anchorNodeId);
|
||||
const anchorNode = findNodeById(graph, anchorNodeId);
|
||||
|
||||
for (const edge of graph.edges || []) {
|
||||
if (edge.fromNodeId === anchorNodeId) candidateIds.add(edge.toNodeId);
|
||||
if (edge.toNodeId === anchorNodeId) candidateIds.add(edge.fromNodeId);
|
||||
}
|
||||
|
||||
if (anchorDecisionId) {
|
||||
for (const edge of graph.edges || []) {
|
||||
if (edge.relationship !== "contained_in") continue;
|
||||
|
||||
if (edge.toNodeId === anchorDecisionId) {
|
||||
const optionId = edge.fromNodeId;
|
||||
candidateIds.add(optionId);
|
||||
for (const innerEdge of graph.edges || []) {
|
||||
if (innerEdge.toNodeId === optionId) {
|
||||
candidateIds.add(innerEdge.fromNodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (anchorNode?.parentId) {
|
||||
candidateIds.add(anchorNode.parentId);
|
||||
for (const node of graph.nodes || []) {
|
||||
if (node.parentId === anchorNode.parentId) candidateIds.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
candidateIds.delete(anchorNodeId);
|
||||
return [...candidateIds];
|
||||
}
|
||||
|
||||
function classifyReconciliationRelation({ anchorNode, candidateNode, graph }) {
|
||||
if (!anchorNode || !candidateNode) return "ambiguous";
|
||||
if (candidateNode.kind !== "unknown") return "unrelated";
|
||||
|
||||
const anchorText = normaliseReconciliationSemanticText(
|
||||
`${anchorNode.label} ${anchorNode.description}`,
|
||||
);
|
||||
const candidateText = normaliseReconciliationSemanticText(
|
||||
`${candidateNode.label} ${candidateNode.description}`,
|
||||
);
|
||||
|
||||
const sharedDecision =
|
||||
findContainingDecisionNodeId(graph, anchorNode.id) &&
|
||||
findContainingDecisionNodeId(graph, anchorNode.id) ===
|
||||
findContainingDecisionNodeId(graph, candidateNode.id);
|
||||
|
||||
const anchorMentionsSigning = /\b(sign|signing|contract status)\b/.test(anchorText);
|
||||
const candidateMentionsDecisionTiming = /\b(when|timing|timeline|make its decision|make their decision)\b/.test(candidateText);
|
||||
const candidateMentionsPendingStatus =
|
||||
/\b(currently unknown|pending|contract|status)\b/.test(candidateText) &&
|
||||
/\b(sign|signing|contract|status)\b/.test(candidateText);
|
||||
const candidateMentionsPureTiming =
|
||||
candidateMentionsDecisionTiming && !candidateMentionsPendingStatus;
|
||||
|
||||
if (
|
||||
sharedDecision &&
|
||||
anchorMentionsSigning &&
|
||||
candidateMentionsPendingStatus &&
|
||||
!candidateMentionsPureTiming
|
||||
) {
|
||||
return "same_proposition";
|
||||
}
|
||||
|
||||
if (sharedDecision && candidateMentionsPureTiming) {
|
||||
return "same_entity_different_dimension";
|
||||
}
|
||||
|
||||
const structurallyConsequential = (graph.edges || []).some(
|
||||
(edge) =>
|
||||
edge.fromNodeId === candidateNode.id &&
|
||||
["depends_on", "causes", "may_cause", "affects", "contained_in"].includes(edge.relationship),
|
||||
);
|
||||
const candidateMentionsRevenue = /\b(revenue|700000|700k|received)\b/.test(candidateText);
|
||||
if (structurallyConsequential && candidateMentionsRevenue) {
|
||||
return "derived_consequence";
|
||||
}
|
||||
|
||||
const downstreamToDecision = (graph.edges || []).some(
|
||||
(edge) => edge.fromNodeId === candidateNode.id && edge.toNodeId === findContainingDecisionNodeId(graph, anchorNode.id),
|
||||
);
|
||||
if (downstreamToDecision) {
|
||||
return "downstream_dependency";
|
||||
}
|
||||
|
||||
return "unrelated";
|
||||
}
|
||||
|
||||
function reconcileDefinitiveEvidenceAcrossLocalGraph(graph, proposalSnapshot) {
|
||||
const definitiveNodeIds = (proposalSnapshot?.updatedNodes || [])
|
||||
.filter((update) => update.newStatus === "resolved")
|
||||
.map((update) => update.nodeId);
|
||||
|
||||
for (const nodeId of definitiveNodeIds) {
|
||||
const anchorNode = findNodeById(graph, nodeId);
|
||||
if (!anchorNode) continue;
|
||||
|
||||
const candidateIds = collectLocalReconciliationCandidateIds(graph, nodeId);
|
||||
for (const candidateId of candidateIds) {
|
||||
const candidateNode = findNodeById(graph, candidateId);
|
||||
if (!candidateNode) continue;
|
||||
|
||||
const relation = classifyReconciliationRelation({
|
||||
anchorNode,
|
||||
candidateNode,
|
||||
graph,
|
||||
});
|
||||
|
||||
if (relation === "same_proposition") {
|
||||
candidateNode.status = "resolved";
|
||||
if (anchorNode.value !== undefined) {
|
||||
candidateNode.value = anchorNode.value ?? candidateNode.value ?? null;
|
||||
if (typeof anchorNode.value === "string" && anchorNode.value.trim().length > 0) {
|
||||
candidateNode.description = anchorNode.value;
|
||||
}
|
||||
}
|
||||
if (!graph.resolvedNodeIds.includes(candidateNode.id)) {
|
||||
graph.resolvedNodeIds.push(candidateNode.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (relation === "derived_consequence") {
|
||||
candidateNode.status = "resolved";
|
||||
candidateNode.value = anchorNode.value ?? candidateNode.value ?? null;
|
||||
if (!graph.resolvedNodeIds.includes(candidateNode.id)) {
|
||||
graph.resolvedNodeIds.push(candidateNode.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildChangesApplied(proposal, affectedNodeIds) {
|
||||
return {
|
||||
addedNodeCount: proposal.addedNodes.length,
|
||||
@@ -1909,6 +2051,33 @@ function selectedQuestionBelongsToChild(graph, selectedQuestion) {
|
||||
return Boolean(findNodeById(graph, selectedQuestion.nodeId)?.parentId);
|
||||
}
|
||||
|
||||
function findContainingDecisionNodeId(graph, nodeId) {
|
||||
if (!graph || !nodeId) return null;
|
||||
const visited = new Set();
|
||||
const queue = [nodeId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentNodeId = queue.shift();
|
||||
if (!currentNodeId || visited.has(currentNodeId)) continue;
|
||||
visited.add(currentNodeId);
|
||||
|
||||
for (const edge of graph.edges || []) {
|
||||
if (edge.relationship !== "contained_in") continue;
|
||||
if (edge.fromNodeId !== currentNodeId) continue;
|
||||
|
||||
const targetNode = findNodeById(graph, edge.toNodeId);
|
||||
if (!targetNode) continue;
|
||||
if (targetNode.kind === "unknown") {
|
||||
return targetNode.id;
|
||||
}
|
||||
|
||||
queue.push(targetNode.id);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normaliseQuestionText(question) {
|
||||
return normaliseText(String(question || "").replace(/\?/g, " "));
|
||||
}
|
||||
@@ -2342,11 +2511,26 @@ function selectPatternCompatibleUnknownCandidate({
|
||||
}
|
||||
|
||||
const excluded = new Set(excludedNodeIds || []);
|
||||
const additionallyExcludedNodeIds = new Set();
|
||||
|
||||
if (activePattern === "decision") {
|
||||
for (const node of graph.nodes || []) {
|
||||
if (node.kind !== "unknown") continue;
|
||||
if (excluded.has(node.id)) continue;
|
||||
if (!isSelectableUnresolvedUnknown(graph, node.id)) continue;
|
||||
if (!hasIncomingContainedInEdge(node.id, graph.edges)) continue;
|
||||
if (hasRemainingMaterialFactors(node.id, graph)) {
|
||||
additionallyExcludedNodeIds.add(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const incompatibleNodeIds = (graph.nodes || [])
|
||||
.filter(
|
||||
(node) =>
|
||||
node.kind === "unknown" &&
|
||||
!excluded.has(node.id) &&
|
||||
!additionallyExcludedNodeIds.has(node.id) &&
|
||||
isSelectableUnresolvedUnknown(graph, node.id),
|
||||
)
|
||||
.filter(
|
||||
@@ -2365,6 +2549,7 @@ function selectPatternCompatibleUnknownCandidate({
|
||||
...new Set([
|
||||
...(resolvedNodeIds || []),
|
||||
...incompatibleNodeIds,
|
||||
...additionallyExcludedNodeIds,
|
||||
...excludedNodeIds,
|
||||
]),
|
||||
]);
|
||||
@@ -4007,6 +4192,13 @@ export function applyValidatedProposal({
|
||||
const resolvedCurrentTurnNodeIds = [
|
||||
...new Set(proposalSnapshot.resolvedUnknownNodeIds || []),
|
||||
];
|
||||
|
||||
reconcileDefinitiveEvidenceAcrossLocalGraph(
|
||||
updatedSituationGraph,
|
||||
proposalSnapshot,
|
||||
);
|
||||
|
||||
|
||||
let postPropagationIncompatibleNodeIds = [];
|
||||
let postPropagationCompatibilityFailures = [];
|
||||
let postPropagationReplacementActions = [];
|
||||
@@ -4284,7 +4476,18 @@ export function applyValidatedProposal({
|
||||
(node) => node.id === deterministicSelection.nodeId,
|
||||
)
|
||||
: null;
|
||||
const formulatedQuestion = selectedNode
|
||||
const selectedNodeContainingDecisionNodeId = selectedNode
|
||||
? findContainingDecisionNodeId(updatedSituationGraph, selectedNode.id)
|
||||
: null;
|
||||
const suppressDecisionSufficiencyConfirmation = Boolean(
|
||||
selectedNodeContainingDecisionNodeId &&
|
||||
selectedNodeContainingDecisionNodeId !== selectedNode?.id &&
|
||||
hasRemainingMaterialFactors(
|
||||
selectedNodeContainingDecisionNodeId,
|
||||
updatedSituationGraph,
|
||||
),
|
||||
);
|
||||
let formulatedQuestion = selectedNode
|
||||
? formulateQuestion({
|
||||
node: selectedNode,
|
||||
graph: updatedSituationGraph,
|
||||
@@ -4295,6 +4498,7 @@ export function applyValidatedProposal({
|
||||
(value) => typeof value === "string" && value.trim().length > 0,
|
||||
),
|
||||
selectionState: deterministicSelection,
|
||||
suppressDecisionSufficiencyConfirmation,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
@@ -4303,6 +4507,43 @@ export function applyValidatedProposal({
|
||||
const plainLanguageNormalisations =
|
||||
formulatedQuestion?.plainLanguageNormalisations ?? [];
|
||||
|
||||
if (
|
||||
deterministicSelection?.status === "selected" &&
|
||||
selectedNode &&
|
||||
formulatedQuestion?.selectedQuestionTemplate ===
|
||||
"decision_threshold_sufficiency_confirmation"
|
||||
) {
|
||||
const containingDecisionNodeId = selectedNodeContainingDecisionNodeId;
|
||||
|
||||
if (
|
||||
containingDecisionNodeId &&
|
||||
containingDecisionNodeId !== selectedNode.id &&
|
||||
hasRemainingMaterialFactors(containingDecisionNodeId, updatedSituationGraph)
|
||||
) {
|
||||
const reformulatedSpecificQuestion = formulateQuestion({
|
||||
node: selectedNode,
|
||||
graph: updatedSituationGraph,
|
||||
context: {
|
||||
resolvedValues: validatedProposal.updatedNodes
|
||||
.map((update) => update.newValue)
|
||||
.filter(
|
||||
(value) => typeof value === "string" && value.trim().length > 0,
|
||||
),
|
||||
selectionState: {
|
||||
...deterministicSelection,
|
||||
reason:
|
||||
"Preserved the specific unresolved material factor because its containing decision still has remaining material factors after the update.",
|
||||
},
|
||||
suppressDecisionSufficiencyConfirmation: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (reformulatedSpecificQuestion) {
|
||||
formulatedQuestion = reformulatedSpecificQuestion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalSelectedQuestion =
|
||||
deterministicSelection?.status === "ambiguous"
|
||||
? {
|
||||
@@ -4360,6 +4601,12 @@ export function applyValidatedProposal({
|
||||
repeatedSelection.nodeId !== deterministicSelection.nodeId
|
||||
) {
|
||||
deterministicSelection = repeatedSelection;
|
||||
} else if (
|
||||
finalSelectedQuestion?.selectedQuestionTemplate !==
|
||||
"decision_threshold_sufficiency_confirmation"
|
||||
) {
|
||||
// Keep the existing specific-factor continuation when repetition rejection
|
||||
// cannot produce a structurally better replacement.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4382,6 +4629,7 @@ export function applyValidatedProposal({
|
||||
(value) => typeof value === "string" && value.trim().length > 0,
|
||||
),
|
||||
selectionState: deterministicSelection,
|
||||
suppressDecisionSufficiencyConfirmation,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -1098,6 +1098,13 @@ function isPrioritisationPatternCandidate(node, graph, relatedNodes = []) {
|
||||
);
|
||||
}
|
||||
|
||||
function isStructuralDecisionNode(node, graph) {
|
||||
if (!node || node.kind !== "unknown") return false;
|
||||
return (graph?.edges || []).some(
|
||||
(edge) => edge.relationship === "contained_in" && edge.toNodeId === node.id,
|
||||
);
|
||||
}
|
||||
|
||||
export function selectReasoningPattern({ node, graph, context = {} }) {
|
||||
const relatedNodes = collectRelatedNodes(node, graph);
|
||||
const patternContext = {
|
||||
@@ -2007,6 +2014,8 @@ export function formulateQuestion({ node, graph, context = {} }) {
|
||||
// Detected transiently from existing state; no persisted field required.
|
||||
if (
|
||||
reasoningPatternSelection.pattern === "decision" &&
|
||||
context.suppressDecisionSufficiencyConfirmation !== true &&
|
||||
isStructuralDecisionNode(node, graph) &&
|
||||
node.status !== "known" &&
|
||||
node.status !== "resolved" &&
|
||||
node.status !== "contradicted" &&
|
||||
|
||||
@@ -318,6 +318,19 @@ function classifyCandidateOrdering(candidates) {
|
||||
};
|
||||
}
|
||||
|
||||
if (topStructuralCandidates.length > 0) {
|
||||
return {
|
||||
displayOrder,
|
||||
best,
|
||||
leadingCandidates: topStructuralCandidates,
|
||||
status: "selected",
|
||||
tieType: "complete_unresolved_tie",
|
||||
usedAlphabeticalOrdering: true,
|
||||
reason:
|
||||
"Leading candidates remained tied after score, structural, and semantic checks, so the stable deterministic display order was used as the final fallback.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
displayOrder,
|
||||
best: null,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user