Adds deriveInvestigationLearning to episode-preparation.js: pure function that maps resolvedNodeIds + Contributions + Findings to investigation learning propositions, preserving node.label question text and contribution-provenance ordering. Owning tests prove: correct provenance, cross-question isolation, eligibility rule (null/agree eligible; not_quite/not_relevant excluded), unresolved exclusion, no-eligible-material omission, text preservation, and provenance authority over originatingTargetNodeId shortcut.
183 lines
6.3 KiB
JavaScript
183 lines
6.3 KiB
JavaScript
/**
|
|
* 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<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,
|
|
};
|
|
}
|
|
|
|
// ── Investigation learning projection ────────────────────────
|
|
|
|
/**
|
|
* Derive deterministic learning from a completed investigation episode.
|
|
*
|
|
* @param {Object} params
|
|
* @param {Object} params.situationGraph - Authoritative SituationGraph (read-only)
|
|
* @param {Array<Object>} params.contributions - All persisted Contributions
|
|
* @param {Array<Object>} params.findings - All canonical Findings
|
|
* @returns {Array<Object>} 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;
|
|
}
|