From ab655e2222683820490d071612fa90859f9fbc3d Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 1 Sep 2026 08:55:38 +0100 Subject: [PATCH] fix(confidence-engine): scope episode closure authority --- lib/graph/apply-proposal.js | 81 +++- lib/graph/decision-sufficiency.js | 39 ++ tests/graph/episode-safeguard-routing.test.js | 399 ++++++++++++++++++ 3 files changed, 506 insertions(+), 13 deletions(-) create mode 100644 tests/graph/episode-safeguard-routing.test.js diff --git a/lib/graph/apply-proposal.js b/lib/graph/apply-proposal.js index b986c3f..c6772af 100644 --- a/lib/graph/apply-proposal.js +++ b/lib/graph/apply-proposal.js @@ -2,6 +2,7 @@ import { describeGraph } from "./builder.js"; import { countRemainingMaterialFactors, hasRemainingMaterialFactors, + isDecisionClosureAuthorityQuestion, isUserConfirmationOfNoRemainingUncertainty, shouldCloseDecision, } from "./decision-sufficiency.js"; @@ -4032,15 +4033,39 @@ export async function applyValidatedProposal({ ); // ── 60B.80 — normalise terminal parent closure without explicit confirmation - // legacy only: episode evidence does not supply a single-answer confirmation string; - // Done and Finding agree are NOT closure authority (settled architecture rule). - const ownershipChanges = isLegacySingleTurn - ? reconcileDecisionClosureOwnership( - situationGraph, - reconciledProposal.proposal, - answer, - ) - : { strippedUpdates: [], strippedResolvedIds: [] }; + // legacy: use raw answer for explicit confirmation detection. + // episode: scan preserved paired turns for a QUALIFIED authority pair; + // question must grant decision-level closure authority AND + // answer must confirm no remaining uncertainty, SAME TURN. + let ownershipAnswer = answer; + if (!isLegacySingleTurn) { + const epTurns = evidenceContext?.isCompletedEpisode + ? evidenceContext.episodeEvidence?.turns + : []; + if (Array.isArray(epTurns) && epTurns.length > 0) { + // Scan all turns — paired check on SAME turn only. + let explicitConfirmationAnswer = null; + for (const turn of epTurns) { + const tQuestion = turn?.question; + const tAnswer = turn?.answer; + if ( + typeof tQuestion === "string" && + typeof tAnswer === "string" && + isDecisionClosureAuthorityQuestion(tQuestion) && + isUserConfirmationOfNoRemainingUncertainty(tAnswer) + ) { + explicitConfirmationAnswer = tAnswer; + break; + } + } + ownershipAnswer = explicitConfirmationAnswer ?? null; + } + } + const ownershipChanges = reconcileDecisionClosureOwnership( + situationGraph, + reconciledProposal.proposal, + ownershipAnswer, + ); if (ownershipChanges.strippedResolvedIds.length > 0) { // Reconciler may have added selectedQuestion = null because the parent // appeared resolved during reconciliation. If we stripped it, restore @@ -4175,10 +4200,40 @@ export async function applyValidatedProposal({ ? findNodeById(graphSnapshot, previousActiveUnknownNodeId) : null; const affectedNodeIds = buildAffectedNodeIds(graphSnapshot, proposalSnapshot); - // For episode evidence: extract turn text from the ordered Q/A sequence. - const _epTurns = evidenceContext?.isCompletedEpisode ? evidenceContext.episodeEvidence.turns : null; - const _derivedQuestion = isLegacySingleTurn ? previousQuestion : (_epTurns?.[0]?.question ?? ""); - const _derivedAnswer = isLegacySingleTurn ? answer : (_epTurns?.[0]?.answer ?? ""); + // For episode evidence: derive comparability override from the relevant Q/A pair, + // not from turns[0]. Scan preserved paired turns for a matching comparability question + // whose answer confirms comparability. + let _derivedQuestion = isLegacySingleTurn ? previousQuestion : ""; + let _derivedAnswer = isLegacySingleTurn ? answer : ""; + if (!isLegacySingleTurn) { + const epTurns = evidenceContext?.isCompletedEpisode + ? evidenceContext.episodeEvidence?.turns + : []; + if (Array.isArray(epTurns)) { + // Scan all turns for a matching comparability Q/A pair. + let found = false; + for (const turn of epTurns) { + const tq = turn?.question; + const ta = turn?.answer; + if ( + typeof tq === "string" && + typeof ta === "string" && + isComparabilityQuestion(tq) && + answerConfirmsComparability(ta) + ) { + _derivedQuestion = tq; + _derivedAnswer = ta; + found = true; + break; + } + } + if (!found) { + // No qualifying comparability pair — do not fabricate confirmation. + _derivedQuestion = ""; + _derivedAnswer = ""; + } + } + } const reasoningResolution = deriveReasoningStateOverride({ graph: graphSnapshot, previousQuestion: _derivedQuestion, diff --git a/lib/graph/decision-sufficiency.js b/lib/graph/decision-sufficiency.js index 4948a09..9cb2e75 100644 --- a/lib/graph/decision-sufficiency.js +++ b/lib/graph/decision-sufficiency.js @@ -65,6 +65,45 @@ export function isUserConfirmationOfNoRemainingUncertainty(answer) { return false; } +// ── Pure: decision-closure authority question predicate ─────────── + +/** + * Determines whether a question explicitly asks the user whether any + * material uncertainty remains at the parent-decision level — i.e. the + * question grants closure authority when answered with confirmation. + * + * Bounded to the decision_threshold sufficiency-confirmation semantic family. + * Focused questions (supplier, budget, etc.) do NOT match even if they + * receive a confirming answer. + * + * Returns true only for: + * - "Is there anything else material that could change which option is better?" + * (the canonical product template) + * - Questions using the closure-family pattern: + * + + * (e.g. "remaining material uncertainty preventing this decision from being closed") + */ +export function isDecisionClosureAuthorityQuestion(question) { + const text = String(question || ""); + const lower = text.toLowerCase(); + + // Direct match for the canonical decision_threshold sufficiency confirmation template + if (/change which option (is |was )?better/i.test(lower)) return true; + + // Closure-family pattern: "remaining-uncertainty" + "closure-action" conjunction + const hasRemainingUncertainty = /any(?:thing)?\s+(?:else\s+)?(?:material\s+)?uncertain(?:ty|ces)/i.test(lower); + if (!hasRemainingUncertainty) return false; + + const hasClosureAction = /\b(closure|close[sd]?|resolve[d]?)\b/i.test(lower); + if (hasClosureAction) return true; + + // "remaining material uncertainty" + decision-referencing words + const hasDecisionRef = /(?:decision|deciding|disposition)\b/i.test(lower); + if (hasDecisionRef) return true; + + return false; +} + // ── Pure: unresolved predicate ───────────────────────────────── /** diff --git a/tests/graph/episode-safeguard-routing.test.js b/tests/graph/episode-safeguard-routing.test.js new file mode 100644 index 0000000..f5c5c6a --- /dev/null +++ b/tests/graph/episode-safeguard-routing.test.js @@ -0,0 +1,399 @@ +/** + * Targeted tests for Defect 1 (parent-decision closure in episode mode) + * and Defect 2 (comparability reasoning-state override routing). + */ + +import { describe, expect, it } from "vitest"; +import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js"; +import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js"; + +// ── Helpers ──────────────────────────────────────────────────────── + +/** Build a parent-decision fixture with options and a material factor. */ +function makeParentClosureFixture() { + const parent = makeNode({ + id: "n-parent", + label: "Should we proceed?", + description: "Parent decision context.", + kind: "unknown", + status: "unknown", + }); + + const optA = makeNode({ + id: "opt-a", + label: "Option A", + description: "Path A.", + kind: "option", + status: "known", + }); + + const optB = makeNode({ + id: "opt-b", + label: "Option B", + description: "Path B.", + kind: "option", + status: "known", + }); + + const factor = makeNode({ + id: "n-factor", + label: "Material uncertainty factor", + description: "A material factor for option A.", + kind: "unknown", + status: "unknown", + }); + + return { + graph: makeGraph({ + centralStatement: "Choosing between paths A and B.", + nodes: [parent, optA, optB, factor], + edges: [ + makeEdge({ id: "e-oa-to-p", fromNodeId: optA.id, toNodeId: parent.id, relationship: "contained_in" }), + makeEdge({ id: "e-ob-to-p", fromNodeId: optB.id, toNodeId: parent.id, relationship: "contained_in" }), + makeEdge({ id: "e-fa", fromNodeId: factor.id, toNodeId: optA.id, relationship: "may_cause" }), + ], + activeUnknownNodeId: "n-factor", + resolvedNodeIds: [], + currentSummary: "Parent closure fixture.", + }), + ids: { parent: parent.id, optA: optA.id, optB: optB.id, factor: factor.id }, + }; +} + +/** Build a comparability fixture. */ +function makeComparabilityFixture() { + const compUnknown = makeNode({ + id: "n-comparability", + label: "Figures on same basis?", + description: "Uncertainty about comparability of two figures.", + kind: "unknown", + status: "unknown", + }); + + const optA = makeNode({ + id: "opt-a", + label: "Option A", + description: "Path A.", + kind: "option", + status: "known", + }); + + const optB = makeNode({ + id: "opt-b", + label: "Option B", + description: "Path B.", + kind: "option", + status: "known", + }); + + const parent = makeNode({ + id: "n-parent", + label: "Decision context", + description: "Parent decision.", + kind: "unknown", + status: "unknown", + }); + + return { + graph: makeGraph({ + centralStatement: "Comparability context.", + nodes: [compUnknown, optA, optB, parent], + edges: [ + makeEdge({ id: "e-oa-to-p", fromNodeId: optA.id, toNodeId: parent.id, relationship: "contained_in" }), + makeEdge({ id: "e-ob-to-p", fromNodeId: optB.id, toNodeId: parent.id, relationship: "contained_in" }), + ], + activeUnknownNodeId: "n-comparability", + resolvedNodeIds: [], + currentSummary: "Comparability fixture.", + }), + ids: { compUnknown: compUnknown.id, parent: parent.id, optA: optA.id, optB: optB.id }, + }; +} + +// ── Defect 1 — Parent-decision closure ───────────────────────────── + +describe("Defect 1 — parent-decision closure in episode mode", () => { + it("legacy explicit closure regression — decision closes with confirmed answer", async () => { + const { graph, ids } = makeParentClosureFixture(); + + const result = await applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { nodeId: "n-factor", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Factor resolved.", reason: "Resolved." }, + { nodeId: ids.parent, previousStatus: "unknown", newStatus: "known", previousValue: null, newValue: "Decision closed.", reason: "Closes parent." }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: ["n-factor"], + affectedNodeIds: [ids.parent], + selectedQuestion: null, + }, + answer: "no other material uncertainty remains", + }); + + expect(result.success).toBe(true); + const parent = result.updatedSituationGraph.nodes.find((n) => n.id === ids.parent); + expect(parent?.status).toBe("known"); + }); + + it("episode closure — no authority prevents terminal parent closure", async () => { + const { graph, ids } = makeParentClosureFixture(); + + const result = await applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { nodeId: "n-factor", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Factor resolved.", reason: "Resolved." }, + { nodeId: ids.parent, previousStatus: "unknown", newStatus: "known", previousValue: null, newValue: "Decision closed.", reason: "Closes parent." }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: ["n-factor"], + affectedNodeIds: [ids.parent], + selectedQuestion: null, + }, + evidenceContext: { + isCompletedEpisode: true, + episodeEvidence: { + turns: [ + { contributionId: "c1", sequence: 1, question: "What did you find?", answer: "All looks good." }, + { contributionId: "c2", sequence: 2, question: "Anything else uncertain?", answer: "No.", }, + ], + }, + }, + }); + + expect(result.success).toBe(true); + // Factor resolved — valid update preserved. + const factor = result.updatedSituationGraph.nodes.find((n) => n.id === "n-factor"); + expect(factor?.status).toBe("resolved"); + // Parent terminal closure prevented — fail-closed. + const parent = result.updatedSituationGraph.nodes.find((n) => n.id === ids.parent); + expect(parent?.status).not.toBe("known"); + }); + + it("episode closure — explicit authority allows closure", async () => { + const { graph, ids } = makeParentClosureFixture(); + + const result = await applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { nodeId: "n-factor", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Factor resolved.", reason: "Resolved." }, + { nodeId: ids.parent, previousStatus: "unknown", newStatus: "known", previousValue: null, newValue: "Decision closed.", reason: "Closes parent." }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: ["n-factor"], + affectedNodeIds: [ids.parent], + selectedQuestion: null, + }, + evidenceContext: { + isCompletedEpisode: true, + episodeEvidence: { + turns: [ + { contributionId: "c1", sequence: 1, question: "What did you find?", answer: "All looks good." }, + { contributionId: "c2", sequence: 2, question: "Is there anything else material that could change which option is better?", answer: "No, there is no remaining material uncertainty.", }, + ], + }, + }, + }); + + expect(result.success).toBe(true); + const factor = result.updatedSituationGraph.nodes.find((n) => n.id === "n-factor"); + expect(factor?.status).toBe("resolved"); + // Explicit authority Q/A pair present — parent closes. + const parent = result.updatedSituationGraph.nodes.find((n) => n.id === ids.parent); + expect(parent?.status).toBe("known"); + }); + + it("focused question + confirming answer does NOT authorise closure", async () => { + // Required 1 — focused false-positive guard + const { graph, ids } = makeParentClosureFixture(); + + const result = await applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { nodeId: "n-factor", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Factor resolved.", reason: "Resolved." }, + { nodeId: ids.parent, previousStatus: "unknown", newStatus: "known", previousValue: null, newValue: "Decision closed.", reason: "Closes parent." }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: ["n-factor"], + affectedNodeIds: [ids.parent], + selectedQuestion: null, + }, + evidenceContext: { + isCompletedEpisode: true, + episodeEvidence: { + turns: [ + { contributionId: "c1", sequence: 1, question: "What did you find?", answer: "All looks good." }, + { contributionId: "c2", sequence: 2, question: "Is there any remaining uncertainty about the supplier?", answer: "No, there is no remaining material uncertainty.", }, + ], + }, + }, + }); + + expect(result.success).toBe(true); + const factor = result.updatedSituationGraph.nodes.find((n) => n.id === "n-factor"); + expect(factor?.status).toBe("resolved"); + // Question is focused, NOT parent-decision authority — closure stripped. + const parent = result.updatedSituationGraph.nodes.find((n) => n.id === ids.parent); + expect(parent?.status).not.toBe("known"); + }); + + it("cross-turn question and answer mixing does NOT authorise closure", async () => { + // Required 3 — cross-turn mixing forbidden + const { graph, ids } = makeParentClosureFixture(); + + const result = await applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { nodeId: "n-factor", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Factor resolved.", reason: "Resolved." }, + { nodeId: ids.parent, previousStatus: "unknown", newStatus: "known", previousValue: null, newValue: "Decision closed.", reason: "Closes parent." }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: ["n-factor"], + affectedNodeIds: [ids.parent], + selectedQuestion: null, + }, + evidenceContext: { + isCompletedEpisode: true, + episodeEvidence: { + turns: [ + { contributionId: "c1", sequence: 1, question: "Is there anything else material that could change which option is better?", answer: "I need to check the budget.", }, + { contributionId: "c2", sequence: 2, question: "Are you satisfied with the analysis so far?", answer: "No, there is no remaining material uncertainty.", }, + ], + }, + }, + }); + + expect(result.success).toBe(true); + const factor = result.updatedSituationGraph.nodes.find((n) => n.id === "n-factor"); + expect(factor?.status).toBe("resolved"); + // Qualifying question and confirming answer are on different turns — closure stripped. + const parent = result.updatedSituationGraph.nodes.find((n) => n.id === ids.parent); + expect(parent?.status).not.toBe("known"); + }); + + it("Finding agree grants closure: NO", async () => { + const { graph, ids } = makeParentClosureFixture(); + + const result = await applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { nodeId: "n-factor", previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Factor resolved.", reason: "Resolved." }, + { nodeId: ids.parent, previousStatus: "unknown", newStatus: "known", previousValue: null, newValue: "Decision closed.", reason: "Closes parent." }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: ["n-factor"], + affectedNodeIds: [ids.parent], + selectedQuestion: null, + }, + evidenceContext: { + isCompletedEpisode: true, + episodeEvidence: { + turns: [ + { contributionId: "c1", sequence: 1, question: "What did you find?", answer: "All looks good." }, + ], + eligibleCanonicalFindings: [ + { findingId: "f-1", contributionId: "c1", proposition: "No material uncertainty.", endorsement: "agree" }, + ], + }, + }, + }); + + expect(result.success).toBe(true); + // Factor resolved — valid update preserved. + const factor = result.updatedSituationGraph.nodes.find((n) => n.id === "n-factor"); + expect(factor?.status).toBe("resolved"); + // No qualifying Q/A pair — parent closure prevented despite Finding agree. + const parent = result.updatedSituationGraph.nodes.find((n) => n.id === ids.parent); + expect(parent?.status).not.toBe("known"); + }); +}); + +// ── Defect 2 — Comparability routing ──────────────────────────────── + +describe("Defect 2 — comparability reasoning-state override routing", () => { + it("comparability relevant turn is not first — episode matches legacy semantics for the right Q/A pair", async () => { + const { graph, ids } = makeComparabilityFixture(); + + const result = await applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { nodeId: ids.compUnknown, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Confirmed comparable.", reason: "Resolved." }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [ids.compUnknown], + affectedNodeIds: [], + selectedQuestion: null, + }, + evidenceContext: { + isCompletedEpisode: true, + episodeEvidence: { + turns: [ + // turn 1: unrelated + { contributionId: "c1", sequence: 1, question: "What evidence do you have?", answer: "Revenue data." }, + // turn 2: comparability Q/A (not first) + { contributionId: "c2", sequence: 2, question: "Are these figures on the same basis and at the same scale?", answer: "Yes, both figures cover the same basis.", }, + ], + }, + }, + }); + + expect(result.success).toBe(true); + // Reasoning state should reflect comparability confirmation from turn 2. + const rs = result.updatedSituationGraph.reasoningState; + expect(rs?.comparabilityStatus).toBe("confirmed"); + expect(rs?.comparabilityReason).toContain("confirmed"); + }); + + it("comparability absent — no fabricated confirmation", async () => { + const { graph, ids } = makeComparabilityFixture(); + + const result = await applyValidatedProposal({ + situationGraph: graph, + proposal: { + updatedNodes: [ + { nodeId: ids.compUnknown, previousStatus: "unknown", newStatus: "resolved", previousValue: null, newValue: "Resolved.", reason: "Resolved." }, + ], + addedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: [ids.compUnknown], + affectedNodeIds: [], + selectedQuestion: null, + }, + evidenceContext: { + isCompletedEpisode: true, + episodeEvidence: { + turns: [ + { contributionId: "c1", sequence: 1, question: "Q?", answer: "A." }, + { contributionId: "c2", sequence: 2, question: "Any other concerns?", answer: "No.", }, + ], + }, + }, + }); + + expect(result.success).toBe(true); + // No comparability pair found — status remains unchanged (uncertain). + const rs = result.updatedSituationGraph.reasoningState; + expect(rs?.comparabilityStatus).not.toBe("confirmed"); + }); +});