Files
confidence-engine/tests/graph/episode-safeguard-routing.test.js
T

400 lines
15 KiB
JavaScript

/**
* 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");
});
});