Files
confidence-engine/tests/graph/episode-preparation.test.js

277 lines
15 KiB
JavaScript

import { describe, expect, it } from "vitest";
import { prepareCompletedEpisode } from "@/lib/graph/episode-preparation.js";
// ── Test fixtures ──────────────────────────────────────────
function makeSituationGraph() {
return {
centralStatement: "Test scenario",
nodes: [{ id: "a-unknown", label: "A Unknown", kind: "unknown", status: "unknown" }, { id: "b-unknown", label: "B Unknown", kind: "unknown", status: "unknown" }],
edges: [],
activeUnknownNodeId: null,
resolvedNodeIds: [],
currentSummary: "Baseline summary",
};
}
function makeContributions({ ids = ["contrib-0001", "contrib-0002", "contrib-0003"], targetNodeId = "a-unknown", sequenceValues = [1, 2, 3], questions = [], answers = [] } = {}) {
return ids.map((id, i) => ({
id,
targetNodeId: targetNodeId === "both" ? (i < 2 ? "a-unknown" : "b-unknown") : targetNodeId,
sequence: sequenceValues[i] ?? i + 1,
question: questions[i] ?? `Question ${i + 1}`,
answer: answers[i] ?? `Answer ${i + 1}`,
observations: [`observation-${id}`],
}));
}
function makeFindings(contribIds) {
return contribIds.map((contribId, i) => ({
id: `finding-find-${String(i).padStart(2, "0")}`,
proposition: `Proposition ${i + 1}`,
status: "provisional",
userDisposition: null,
originatingTargetNodeId: contribId.startsWith("find-a") ? "a-unknown" : "b-unknown",
contributionId: contribId,
sourceObservation: `Source observation ${i + 1}`,
}));
}
// ── 1. Target scoping ─────────────────────────────────────
describe("prepareCompletedEpisode — target scoping", () => {
it("only includes Contributions for the completed target", () => {
const graph = makeSituationGraph();
const contributions = [
{ id: "contrib-a1", targetNodeId: "a-unknown", sequence: 1, question: "Q1?", answer: "A1" },
{ id: "contrib-b1", targetNodeId: "b-unknown", sequence: 1, question: "Q2?", answer: "A2" },
{ id: "contrib-a2", targetNodeId: "a-unknown", sequence: 2, question: "Q3?", answer: "A3" },
];
const findings = [
{ id: "f1", proposition: "P1", status: "provisional", userDisposition: null, originatingTargetNodeId: "a-unknown", contributionId: "contrib-a1", sourceObservation: "S1" },
{ id: "f2", proposition: "P2", status: "provisional", userDisposition: null, originatingTargetNodeId: "b-unknown", contributionId: "contrib-b1", sourceObservation: "S2" },
];
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
expect(result.targetNodeId).toBe("a-unknown");
expect(result.turns).toHaveLength(2);
expect(result.turns.map((t) => t.contributionId)).toEqual(["contrib-a1", "contrib-a2"]);
// Findings from non-scoped Contributions must not appear in eligible or excluded
const allBucketedIds = new Set([...result.eligibleCanonicalFindings, ...result.excludedFindingProvenance].map((f) => f.findingId));
expect(allBucketedIds.has("f2")).toBe(false);
});
});
// ── 2. Ordering ────────────────────────────────────────────
describe("prepareCompletedEpisode — ordering", () => {
it("orders contributions by sequence ascending regardless of input order", () => {
const graph = makeSituationGraph();
const contributions = [
{ id: "contrib-0003", targetNodeId: "a-unknown", sequence: 3, question: "Q3?", answer: "A3" },
{ id: "contrib-0001", targetNodeId: "a-unknown", sequence: 1, question: "Q1?", answer: "A1" },
{ id: "contrib-0002", targetNodeId: "a-unknown", sequence: 2, question: "Q2?", answer: "A2" },
];
const findings = [];
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
expect(result.turns.map((t) => t.sequence)).toEqual([1, 2, 3]);
expect(result.turns[0].contributionId).toBe("contrib-0001");
expect(result.turns[1].contributionId).toBe("contrib-0002");
expect(result.turns[2].contributionId).toBe("contrib-0003");
});
});
// ── 3. Eligibility ─────────────────────────────────────────
describe("prepareCompletedEpisode — eligibility bucketing", () => {
it("routes null and agree to eligibleCanonicalFindings", () => {
const graph = makeSituationGraph();
const contributions = makeContributions({ ids: ["contrib-e1"], targetNodeId: "a-unknown" });
const findings = [
{ id: "find-null", proposition: "Null prop", status: "provisional", userDisposition: null, originatingTargetNodeId: "a-unknown", contributionId: "contrib-e1", sourceObservation: "Source null" },
{ id: "find-agree", proposition: "Agree prop", status: "provisional", userDisposition: "agree", originatingTargetNodeId: "a-unknown", contributionId: "contrib-e1", sourceObservation: "Source agree" },
];
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
expect(result.eligibleCanonicalFindings).toHaveLength(2);
expect(result.excludedFindingProvenance).toHaveLength(0);
});
it("routes not_relevant and not_quite to excludedFindingProvenance", () => {
const graph = makeSituationGraph();
const contributions = makeContributions({ ids: ["contrib-x1", "contrib-x2"], targetNodeId: "a-unknown" });
const findings = [
{ id: "find-notrel", proposition: "Not rel prop", status: "provisional", userDisposition: "not_relevant", originatingTargetNodeId: "a-unknown", contributionId: "contrib-x1", sourceObservation: "Source NR" },
{ id: "find-notquite", proposition: "Not quite prop", status: "provisional", userDisposition: "not_quite", originatingTargetNodeId: "a-unknown", contributionId: "contrib-x2", sourceObservation: "Source NQ" },
];
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
expect(result.eligibleCanonicalFindings).toHaveLength(0);
expect(result.excludedFindingProvenance).toHaveLength(2);
});
});
// ── 4. Silence is not agreement ───────────────────────────
describe("prepareCompletedEpisode — silence is not agreement", () => {
it("preserves null as distinguishable from agree in endorsement field", () => {
const graph = makeSituationGraph();
const contributions = makeContributions({ ids: ["contrib-s1", "contrib-s2"], targetNodeId: "a-unknown" });
const findings = [
{ id: "find-null", proposition: "Silence prop", status: "provisional", userDisposition: null, originatingTargetNodeId: "a-unknown", contributionId: "contrib-s1", sourceObservation: "Source silence" },
{ id: "find-agree", proposition: "Agree prop", status: "provisional", userDisposition: "agree", originatingTargetNodeId: "a-unknown", contributionId: "contrib-s2", sourceObservation: "Source agree" },
];
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
const nullFinding = result.eligibleCanonicalFindings.find((f) => f.findingId === "find-null");
const agreeFinding = result.eligibleCanonicalFindings.find((f) => f.findingId === "find-agree");
expect(nullFinding.endorsement).toBe(null);
expect(agreeFinding.endorsement).toBe("agree");
expect(nullFinding.endorsement !== agreeFinding.endorsement).toBe(true);
});
});
// ── 5. Correction provenance ──────────────────────────────
describe("prepareCompletedEpisode — correction provenance", () => {
it("preserves distinct proposition and sourceObservation for corrected eligible Finding", () => {
const graph = makeSituationGraph();
const contributions = makeContributions({ ids: ["contrib-c1"], targetNodeId: "a-unknown" });
const findings = [
{
id: "find-corrected",
proposition: "Corrected canonical text — user fixed the observation",
status: "provisional",
userDisposition: null,
originatingTargetNodeId: "a-unknown",
contributionId: "contrib-c1",
sourceObservation: "Original model observation that was corrected",
},
];
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
expect(result.eligibleCanonicalFindings).toHaveLength(1);
const eligible = result.eligibleCanonicalFindings[0];
expect(eligible.proposition).toBe("Corrected canonical text — user fixed the observation");
expect(eligible.sourceObservation).toBe("Original model observation that was corrected");
expect(eligible.proposition).not.toBe(eligible.sourceObservation);
});
});
// ── 6. Excluded provenance ────────────────────────────────
describe("prepareCompletedEpisode — excluded provenance", () => {
it("retains proposition, sourceObservation, disposition, contributionId for not_relevant Finding", () => {
const graph = makeSituationGraph();
const contributions = makeContributions({ ids: ["contrib-ep1"], targetNodeId: "a-unknown" });
const findings = [
{ id: "find-notrel", proposition: "Not relevant proposition text", status: "provisional", userDisposition: "not_relevant", originatingTargetNodeId: "a-unknown", contributionId: "contrib-ep1", sourceObservation: "Immutable original observation" },
];
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
expect(result.excludedFindingProvenance).toHaveLength(1);
const excluded = result.excludedFindingProvenance[0];
expect(excluded.findingId).toBe("find-notrel");
expect(excluded.proposition).toBe("Not relevant proposition text");
expect(excluded.sourceObservation).toBe("Immutable original observation");
expect(excluded.disposition).toBe("not_relevant");
expect(excluded.contributionId).toBe("contrib-ep1");
});
});
// ── 7. Membership authority ───────────────────────────────
describe("prepareCompletedEpisode — membership authority", () => {
it("excludes Findings whose contributionId does not reference a scoped Contribution even if originatingTargetNodeId matches", () => {
const graph = makeSituationGraph();
// contrib-xtra targets "b-unknown" so it is NOT part of the scoped episode for target A
const contributions = [
{ id: "contrib-a1", targetNodeId: "a-unknown", sequence: 1, question: "Q?", answer: "A" },
{ id: "contrib-xtra", targetNodeId: "b-unknown", sequence: 1, question: "Q2?", answer: "A2" },
];
// Finding references contrib-xtra which is not scoped to target A.
// Even though originatingTargetNodeId === "a-unknown", membership is determined by contributionId.
const findings = [
{ id: "find-orphan", proposition: "Orphan prop", status: "provisional", userDisposition: null, originatingTargetNodeId: "a-unknown", contributionId: "contrib-xtra", sourceObservation: "Orig obs" },
];
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
// Nothing should be bucketed because the only Finding references a non-scoped Contribution
expect(result.eligibleCanonicalFindings).toHaveLength(0);
expect(result.excludedFindingProvenance).toHaveLength(0);
const allBucketed = [...result.eligibleCanonicalFindings, ...result.excludedFindingProvenance];
expect(allBucketed.some((f) => f.findingId === "find-orphan")).toBe(false);
});
it("includes scoped Finding as eligible when its contributionId references a scoped Contribution", () => {
const graph = makeSituationGraph();
const contributions = [
{ id: "contrib-val1", targetNodeId: "a-unknown", sequence: 1, question: "Q?", answer: "A" },
];
const findings = [
{ id: "find-valid2", proposition: "Valid prop 2", status: "provisional", userDisposition: null, originatingTargetNodeId: "x-unknown", contributionId: "contrib-val1", sourceObservation: "Orig valid" },
];
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
// Even though originatingTargetNodeId !== targetNodeId, the Contribution provenance chain authorizes inclusion
expect(result.eligibleCanonicalFindings).toHaveLength(1);
expect(result.eligibleCanonicalFindings[0].findingId).toBe("find-valid2");
});
});
// ── 8. Purity ──────────────────────────────────────────────
describe("prepareCompletedEpisode — purity", () => {
it("does not mutate situationGraph, contributions, or findings", () => {
const graph = makeSituationGraph();
const contributions = [
{ id: "contrib-p1", targetNodeId: "a-unknown", sequence: 1, question: "Q?", answer: "A" },
];
const findings = [
{ id: "find-p1", proposition: "Prop P", status: "provisional", userDisposition: null, originatingTargetNodeId: "a-unknown", contributionId: "contrib-p1", sourceObservation: "Source P" },
];
// Snapshot originals before call
const graphSnapshot = JSON.parse(JSON.stringify(graph));
const contribsSnapshot = contributions.map((c) => JSON.parse(JSON.stringify(c)));
const findingsSnapshot = findings.map((f) => JSON.parse(JSON.stringify(f)));
prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
expect(graph).toEqual(graphSnapshot);
expect(contributions).toEqual(contribsSnapshot);
expect(findings).toEqual(findingsSnapshot);
// Verify no new properties were added to inputs
expect(Object.keys(graph)).toEqual(Object.keys(graphSnapshot));
expect(Object.keys(contributions[0])).toEqual(Object.keys(contribsSnapshot[0]));
expect(Object.keys(findings[0])).toEqual(Object.keys(findingsSnapshot[0]));
});
it("returns a new object that is independent from inputs", () => {
const graph = makeSituationGraph();
const contributions = [
{ id: "contrib-i1", targetNodeId: "a-unknown", sequence: 1, question: "Q?", answer: "A" },
];
const findings = [];
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
expect(result).not.toBe(graph);
expect(result).not.toBe(contributions);
expect(result).not.toBe(findings);
});
});