fix(confidence-engine): scope episode closure authority

This commit is contained in:
2026-09-01 08:55:38 +01:00
parent 0752c53a25
commit ab655e2222
3 changed files with 506 additions and 13 deletions
+68 -13
View File
@@ -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,
+39
View File
@@ -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:
* <remaining-uncertainty-phrase> + <closure-action-phrase>
* (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 ─────────────────────────────────
/**
@@ -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");
});
});