/** * 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 ────────────────────────────────── export const ELIGIBLE_DISPOSITIONS = new Set([null, "agree"]); export 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, }; } // ── Investigation learning projection ──────────────────────── /** * Derive deterministic learning from a completed investigation episode. * * @param {Object} params * @param {Object} params.situationGraph - Authoritative SituationGraph (read-only) * @param {Array} params.contributions - All persisted Contributions * @param {Array} params.findings - All canonical Findings * @returns {Array} investigationLearning — ordered by resolvedNodeIds, then Contribution.sequence */ export function deriveInvestigationLearning({ situationGraph, contributions = [], findings = [] }) { const resolvedNodeIds = situationGraph?.resolvedNodeIds ?? []; // Build lookup from node id to node (for question text) const nodeById = new Map(); for (const node of situationGraph?.nodes ?? []) { if (node?.id) { nodeById.set(node.id, node); } } const result = []; for (const nodeId of resolvedNodeIds) { const node = nodeById.get(nodeId); if (!node) continue; // Collect scoped contributions ordered by sequence const scopedContribs = []; for (const contrib of contributions) { if (contrib?.targetNodeId === nodeId) { scopedContribs.push(contrib); } } const orderedContribs = scopedContribs .slice() .sort(function(a, b) { var aSeq = a?.sequence != null ? a.sequence : Infinity; var bSeq = b?.sequence != null ? b.sequence : Infinity; return aSeq - bSeq; }); // Build contribution ID set for provenance chain const contribIds = new Set(orderedContribs.map(function(c) { return c.id; })); // Collect eligible findings from scoped contributions via Contribution provenance only var propositions = []; for (var i = 0; i < orderedContribs.length; i++) { var contrib = orderedContribs[i]; for (var j = 0; j < findings.length; j++) { var finding = findings[j]; if (finding?.contributionId === contrib.id && ELIGIBLE_DISPOSITIONS.has(finding?.userDisposition ?? null)) { propositions.push({ findingId: finding.id ?? null, proposition: finding.proposition ?? "", userDisposition: finding.userDisposition ?? null, }); } } } // Only include questions that produced at least one eligible proposition if (propositions.length > 0) { result.push({ nodeId: nodeId, questionText: node.label ?? "", propositions: propositions, }); } } return result; }