From efa39f52deacd1380c7427c0b49d87696bb2f149 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 1 Sep 2026 06:48:55 +0100 Subject: [PATCH] feat(confidence-engine): prepare completed episode evidence --- lib/graph/episode-preparation.js | 106 +++++++++ tests/graph/episode-preparation.test.js | 276 ++++++++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 lib/graph/episode-preparation.js create mode 100644 tests/graph/episode-preparation.test.js diff --git a/lib/graph/episode-preparation.js b/lib/graph/episode-preparation.js new file mode 100644 index 0000000..6bb56c1 --- /dev/null +++ b/lib/graph/episode-preparation.js @@ -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} params.contributions - All focused Contributions + * @param {Array} 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, + }; +} diff --git a/tests/graph/episode-preparation.test.js b/tests/graph/episode-preparation.test.js new file mode 100644 index 0000000..5b74be5 --- /dev/null +++ b/tests/graph/episode-preparation.test.js @@ -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); + }); +});