feat(confidence-engine): prepare completed episode evidence

This commit is contained in:
2026-09-01 06:48:55 +01:00
parent 18a7eb97cc
commit efa39f52de
2 changed files with 382 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
/**
* Episode preparation — deterministically prepare a completed focused episode
* for future authoritative graph reasoning.
*
* Pure domain transformation:
* SituationGraph + targetNodeId + Contributions + Findings → preparedEpisode
*
* Purity contract:
* - No mutation of inputs (situationGraph, contributions, findings)
* - No storage I/O
* - No fetch / provider / model calls
* - No GraphUpdateProposal generation
* - No reasoningState modification
*/
// ── Eligibility constants ──────────────────────────────────
const ELIGIBLE_DISPOSITIONS = new Set([null, "agree"]);
const EXCLUDED_DISPOSITIONS = new Set(["not_relevant", "not_quite"]);
// ── Public API ─────────────────────────────────────────────
/**
* Prepare a completed focused episode for deterministic graph reasoning.
*
* @param {Object} params
* @param {Object} params.situationGraph - Authoritative SituationGraph (read-only)
* @param {string} params.targetNodeId - The completed target node ID
* @param {Array<Object>} params.contributions - All focused Contributions
* @param {Array<Object>} params.findings - All canonical Findings
* @returns {Object} preparedEpisode
*/
export function prepareCompletedEpisode({ situationGraph, targetNodeId, contributions, findings }) {
// ── 1. Scope contributions to the completed target ───────
const scopedContributions = [];
for (const contrib of contributions) {
if (contrib?.targetNodeId === targetNodeId) {
scopedContributions.push(contrib);
}
}
// ── 2. Order deterministically by sequence ───────────────
const orderedContributions = scopedContributions.slice().sort((a, b) => {
const aSeq = a?.sequence != null ? a.sequence : Infinity;
const bSeq = b?.sequence != null ? b.sequence : Infinity;
return aSeq - bSeq;
});
// ── 3. Build contribution ID lookup for provenance chain ─
const contribIds = new Set(orderedContributions.map((c) => c.id));
// ── 4. Prepare turns (ordered Q/A pairs) ────────────────
const turns = orderedContributions.map((contrib, idx) => ({
contributionId: contrib.id ?? null,
sequence: contrib.sequence != null ? contrib.sequence : idx + 1,
question: contrib.question ?? "",
answer: contrib.answer ?? "",
}));
// ── 5. Bucket Findings by eligibility ───────────────────
const eligibleCanonicalFindings = [];
const excludedFindingProvenance = [];
for (const finding of findings || []) {
if (!finding?.contributionId) continue;
// Membership authority: only Findings whose contributionId references a scoped Contribution
if (!contribIds.has(finding.contributionId)) continue;
const disposition = finding.userDisposition ?? null;
const bucketedFinding = {
findingId: finding.id ?? null,
contributionId: finding.contributionId,
proposition: finding.proposition ?? "",
sourceObservation: finding.sourceObservation ?? "",
disposition,
};
if (ELIGIBLE_DISPOSITIONS.has(disposition)) {
eligibleCanonicalFindings.push({
...bucketedFinding,
endorsement: disposition, // null or "agree"
});
} else if (EXCLUDED_DISPOSITIONS.has(disposition)) {
excludedFindingProvenance.push(bucketedFinding);
}
}
// ── 6. Compose prepared episode ─────────────────────────
return {
situationGraph: situationGraph ?? null,
targetNodeId,
turns,
eligibleCanonicalFindings,
excludedFindingProvenance,
};
}
+276
View File
@@ -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);
});
});