feat(confidence-engine): prepare completed episode evidence
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user