From 8311a176a5b11121c532d7646fd880d12ff0c6c3 Mon Sep 17 00:00:00 2001 From: robbond Date: Fri, 14 Aug 2026 18:01:27 +0100 Subject: [PATCH] fix(reasoning): ask for missing sufficiency confirmation --- lib/graph/question-formulator.js | 41 ++++ tests/graph/question-formulator.test.js | 248 ++++++++++++++++++++++++ 2 files changed, 289 insertions(+) diff --git a/lib/graph/question-formulator.js b/lib/graph/question-formulator.js index e14075a..f9708f4 100644 --- a/lib/graph/question-formulator.js +++ b/lib/graph/question-formulator.js @@ -1,3 +1,8 @@ +import { + hasRemainingMaterialFactors, + isUserConfirmationOfNoRemainingUncertainty, +} from "./decision-sufficiency.js"; + function normaliseText(value) { return String(value || "") .toLowerCase() @@ -1997,6 +2002,42 @@ export function formulateQuestion({ node, graph, context = {} }) { }; } + // ── State B: unresolved decision with zero remaining factors but + // no explicit sufficiency confirmation yet ──────────────── + // Detected transiently from existing state; no persisted field required. + if ( + reasoningPatternSelection.pattern === "decision" && + node.kind !== "unknown" && + node.status !== "known" && + node.status !== "resolved" && + node.status !== "contradicted" && + hasRemainingMaterialFactors(node.id, graph) === false + ) { + const resolved = context.resolvedValues || []; + const hasConfirmation = resolved.some((v) => + isUserConfirmationOfNoRemainingUncertainty(v), + ); + + if (!hasConfirmation) { + return { + question: + "Is there anything else material that could change which option is better?", + reason: + "All represented material factors are resolved but explicit sufficiency confirmation has not yet been provided.", + strategy: null, + investigationStrategy: null, + reasoningPattern: reasoningPatternSelection.pattern, + reasoningPatternReason: reasoningPatternSelection.reason, + questionFamily: "decision_threshold", + allowedQuestionFamilies, + rejectedQuestionFamilies, + selectedQuestionTemplate: "decision_threshold_sufficiency_confirmation", + questionComplexity: null, + plainLanguageNormalisations: [], + }; + } + } + const investigationStrategy = selectInvestigationStrategy({ node, graph, diff --git a/tests/graph/question-formulator.test.js b/tests/graph/question-formulator.test.js index 3146adb..9478e83 100644 --- a/tests/graph/question-formulator.test.js +++ b/tests/graph/question-formulator.test.js @@ -7,6 +7,10 @@ import { selectInvestigationStrategy, } from "@/lib/graph/question-formulator.js"; import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; +import { + hasRemainingMaterialFactors, + isUserConfirmationOfNoRemainingUncertainty, +} from "@/lib/graph/decision-sufficiency.js"; function makeGraphFor(node, extra = {}) { return makeGraph({ @@ -1093,4 +1097,248 @@ describe("formulateQuestion", () => { expect(unknown.description).toBe(description); }); + + // ── 60B.73 — missing sufficiency confirmation question ────────── + + it("60B.73 Test 1 — exact State B gets sufficiency question", () => { + const decision = makeNode({ + id: "n_product_launch_decision", + label: "Whether to launch product X", + description: "Launch vs not launch decision for product X.", + kind: "state", + status: "unknown", + confidence: "medium", + childIds: [], + }); + + const graph = makeGraphFor(decision, { + nodes: [], + edges: [], + }); + + const result = formulateQuestion({ + node: decision, + graph, + context: { resolvedValues: [] }, + }); + + // Decision identity preserved + expect(result.questionFamily).toBe("decision_threshold"); + expect(result.selectedQuestionTemplate).toBe( + "decision_threshold_sufficiency_confirmation", + ); + // Generic threshold wording ABSENT + expect(result.question).not.toContain("What outcome would demonstrate enough value"); + expect(result.question.toLowerCase()).not.toContain("what outcome would be sufficient to justify this decision"); + }); + + it("60B.73 Test 2 — question allows missing-factor discovery", () => { + const decision = makeNode({ + id: "n_product_launch_decision", + label: "Whether to launch product X", + description: "Launch vs not launch decision for product X.", + kind: "state", + status: "unknown", + confidence: "medium", + }); + + const graph = makeGraphFor(decision, { nodes: [], edges: [] }); + + const result = formulateQuestion({ + node: decision, + graph, + context: { resolvedValues: [] }, + }); + + // Open enough to support: "Yes — customer implementation capacity is still uncertain." + expect(result.question).toContain("anything else material"); + expect(result.question).toContain("could change which option is better"); + }); + + it("60B.73 Test 3 — genuine remaining factor preserves existing path", () => { + const factor = makeNode({ + id: "n_customer_readiness", + label: "Customer readiness level", + description: "How ready the customer is to adopt.", + kind: "unknown", + status: "unknown", + confidence: "medium", + parentId: "n_product_launch_decision", + }); + + const decision = makeNode({ + id: "n_product_launch_decision", + label: "Whether to launch product X", + description: "Launch vs not launch decision.", + kind: "state", + status: "unknown", + confidence: "medium", + childIds: [factor.id], + }); + + const graph = makeGraphFor(factor, { + nodes: [decision], + edges: [], + }); + + // Verify factor is genuinely unresolved + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(true); + + const result = formulateQuestion({ + node: decision, + graph, + context: { resolvedValues: [] }, + }); + + // Should NOT select sufficiency template — normal path preserved + expect(result.selectedQuestionTemplate).not.toBe( + "decision_threshold_sufficiency_confirmation", + ); + }); + + it("60B.73 Test 4 — explicit sufficiency confirmation preserves closure/no-question path", () => { + const decision = makeNode({ + id: "n_product_launch_decision", + label: "Whether to launch product X", + description: "Launch vs not launch decision.", + kind: "state", + status: "unknown", + confidence: "medium", + }); + + const graph = makeGraphFor(decision, { nodes: [], edges: [] }); + + const result = formulateQuestion({ + node: decision, + graph, + context: { + resolvedValues: ["no other material uncertainty remains"], + }, + }); + + // Should NOT select sufficiency template — confirmation present → normal path + expect(result.selectedQuestionTemplate).not.toBe( + "decision_threshold_sufficiency_confirmation", + ); + }); + + it("60B.73 Test 5 — non-decision unknown unchanged", () => { + const unknown = makeNode({ + id: "n_evidence_unknown", + label: "Evidence of demand", + description: "Need evidence of demand.", + kind: "unknown", + status: "unknown", + confidence: "medium", + }); + + const graph = makeGraphFor(unknown); + + const result = formulateQuestion({ + node: unknown, + graph, + }); + + // Should NOT be sufficiency confirmation — normal path + expect(result.selectedQuestionTemplate).not.toBe( + "decision_threshold_sufficiency_confirmation", + ); + }); + + it("60B.73 Test 6 — ordinary decision_threshold preserved for genuine threshold cases", () => { + const unknown = makeNode({ + id: "n-commercial", + label: "Uncertainty regarding the commercial value of the product", + description: + "Commercial justification remains unclear because the decision depends on it.", + kind: "unknown", + status: "unknown", + confidence: "high", + parentId: "n-decision", + }); + + const decision = makeNode({ + id: "n-decision", + label: "Build decision", + description: "Decision introduced by the answer.", + kind: "state", + status: "known", + confidence: "medium", + childIds: [unknown.id], + value: "Deciding whether to build the product", + }); + + const graph = makeGraphFor(unknown, { + nodes: [decision], + resolvedNodeIds: [decision.id], + }); + + const result = formulateQuestion({ node: unknown, graph }); + + // Should still produce decision_threshold (the normal path for unknown factors) + expect(result.questionFamily).toBe("decision_threshold"); + }); + + it("60B.73 Test 7 — resolved factor remains resolved", () => { + const factor = makeNode({ + id: "n_customer_readiness", + label: "Customer readiness confirmed", + description: "Ready to adopt.", + kind: "unknown", + status: "resolved", + confidence: "high", + parentId: "n_product_launch_decision", + }); + + const decision = makeNode({ + id: "n_product_launch_decision", + label: "Whether to launch product X", + description: "Launch vs not launch decision.", + kind: "state", + status: "unknown", + confidence: "medium", + childIds: [factor.id], + }); + + const graph = makeGraphFor(factor, { + nodes: [decision], + edges: [], + }); + + // Verified: zero remaining factors (factor is resolved) + expect(hasRemainingMaterialFactors(decision.id, graph)).toBe(false); + + const result = formulateQuestion({ + node: decision, + graph, + context: { resolvedValues: [] }, + }); + + // Should get sufficiency confirmation (State B — no remaining factors, no confirmation) + expect(result.selectedQuestionTemplate).toBe( + "decision_threshold_sufficiency_confirmation", + ); + }); + + it("60B.73 Test 8 — same decision identity preserved", () => { + const decision = makeNode({ + id: "n_product_launch_decision", + label: "Whether to launch product X", + description: "Launch vs not launch decision.", + kind: "state", + status: "unknown", + confidence: "medium", + }); + + const graph = makeGraphFor(decision, { nodes: [], edges: [] }); + + const result = formulateQuestion({ + node: decision, + graph, + context: { resolvedValues: [] }, + }); + + // Decision identity not changed by sufficiency question + expect(result.reason).toContain("material factors"); + }); });