fix(confidence-engine): scope episode closure authority
This commit is contained in:
+68
-13
@@ -2,6 +2,7 @@ import { describeGraph } from "./builder.js";
|
|||||||
import {
|
import {
|
||||||
countRemainingMaterialFactors,
|
countRemainingMaterialFactors,
|
||||||
hasRemainingMaterialFactors,
|
hasRemainingMaterialFactors,
|
||||||
|
isDecisionClosureAuthorityQuestion,
|
||||||
isUserConfirmationOfNoRemainingUncertainty,
|
isUserConfirmationOfNoRemainingUncertainty,
|
||||||
shouldCloseDecision,
|
shouldCloseDecision,
|
||||||
} from "./decision-sufficiency.js";
|
} from "./decision-sufficiency.js";
|
||||||
@@ -4032,15 +4033,39 @@ export async function applyValidatedProposal({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── 60B.80 — normalise terminal parent closure without explicit confirmation
|
// ── 60B.80 — normalise terminal parent closure without explicit confirmation
|
||||||
// legacy only: episode evidence does not supply a single-answer confirmation string;
|
// legacy: use raw answer for explicit confirmation detection.
|
||||||
// Done and Finding agree are NOT closure authority (settled architecture rule).
|
// episode: scan preserved paired turns for a QUALIFIED authority pair;
|
||||||
const ownershipChanges = isLegacySingleTurn
|
// question must grant decision-level closure authority AND
|
||||||
? reconcileDecisionClosureOwnership(
|
// answer must confirm no remaining uncertainty, SAME TURN.
|
||||||
situationGraph,
|
let ownershipAnswer = answer;
|
||||||
reconciledProposal.proposal,
|
if (!isLegacySingleTurn) {
|
||||||
answer,
|
const epTurns = evidenceContext?.isCompletedEpisode
|
||||||
)
|
? evidenceContext.episodeEvidence?.turns
|
||||||
: { strippedUpdates: [], strippedResolvedIds: [] };
|
: [];
|
||||||
|
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) {
|
if (ownershipChanges.strippedResolvedIds.length > 0) {
|
||||||
// Reconciler may have added selectedQuestion = null because the parent
|
// Reconciler may have added selectedQuestion = null because the parent
|
||||||
// appeared resolved during reconciliation. If we stripped it, restore
|
// appeared resolved during reconciliation. If we stripped it, restore
|
||||||
@@ -4175,10 +4200,40 @@ export async function applyValidatedProposal({
|
|||||||
? findNodeById(graphSnapshot, previousActiveUnknownNodeId)
|
? findNodeById(graphSnapshot, previousActiveUnknownNodeId)
|
||||||
: null;
|
: null;
|
||||||
const affectedNodeIds = buildAffectedNodeIds(graphSnapshot, proposalSnapshot);
|
const affectedNodeIds = buildAffectedNodeIds(graphSnapshot, proposalSnapshot);
|
||||||
// For episode evidence: extract turn text from the ordered Q/A sequence.
|
// For episode evidence: derive comparability override from the relevant Q/A pair,
|
||||||
const _epTurns = evidenceContext?.isCompletedEpisode ? evidenceContext.episodeEvidence.turns : null;
|
// not from turns[0]. Scan preserved paired turns for a matching comparability question
|
||||||
const _derivedQuestion = isLegacySingleTurn ? previousQuestion : (_epTurns?.[0]?.question ?? "");
|
// whose answer confirms comparability.
|
||||||
const _derivedAnswer = isLegacySingleTurn ? answer : (_epTurns?.[0]?.answer ?? "");
|
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({
|
const reasoningResolution = deriveReasoningStateOverride({
|
||||||
graph: graphSnapshot,
|
graph: graphSnapshot,
|
||||||
previousQuestion: _derivedQuestion,
|
previousQuestion: _derivedQuestion,
|
||||||
|
|||||||
@@ -65,6 +65,45 @@ export function isUserConfirmationOfNoRemainingUncertainty(answer) {
|
|||||||
return false;
|
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 ─────────────────────────────────
|
// ── 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user