/** * 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, }; }