400 lines
15 KiB
JavaScript
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");
|
|
});
|
|
});
|