diff --git a/lib/graph/episode-preparation.js b/lib/graph/episode-preparation.js index 6bb56c1..ac3c672 100644 --- a/lib/graph/episode-preparation.js +++ b/lib/graph/episode-preparation.js @@ -15,9 +15,9 @@ // ── Eligibility constants ────────────────────────────────── -const ELIGIBLE_DISPOSITIONS = new Set([null, "agree"]); +export const ELIGIBLE_DISPOSITIONS = new Set([null, "agree"]); -const EXCLUDED_DISPOSITIONS = new Set(["not_relevant", "not_quite"]); +export const EXCLUDED_DISPOSITIONS = new Set(["not_relevant", "not_quite"]); // ── Public API ───────────────────────────────────────────── @@ -104,3 +104,79 @@ export function prepareCompletedEpisode({ situationGraph, targetNodeId, contribu 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; +} diff --git a/tests/graph/investigation-learning-projection.test.js b/tests/graph/investigation-learning-projection.test.js new file mode 100644 index 0000000..e06a9a5 --- /dev/null +++ b/tests/graph/investigation-learning-projection.test.js @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import { deriveInvestigationLearning } from "@/lib/graph/episode-preparation.js"; + +// --- Fixtures --- +function makeGraph(resolvedIds) { + return { + centralStatement: "Test scenario", + nodes: [ + { id: "node-A", label: "How do we improve customer retention?", kind: "unknown", status: "unknown" }, + { id: "node-B", label: "Which market segment to target?", kind: "unknown", status: "unknown" }, + { id: "node-C", label: "What is the budget constraint?", kind: "unknown", status: "unknown" } + ], + edges: [], + activeUnknownNodeId: null, + resolvedNodeIds: resolvedIds ?? [], + currentSummary: "Baseline", + }; +} + +// === Case 1: correct provenance === + +describe("deriveInvestigationLearning - correct provenance", () => { + it("returns Finding A under Question A via Contribution provenance chain", () => { + const graph = makeGraph(["node-A"]); + const contributions = [ + { id: "contrib-A1", targetNodeId: "node-A", sequence: 1, question: "Q1?", answer: "A1" }, + { id: "contrib-A2", targetNodeId: "node-A", sequence: 2, question: "Q2?", answer: "A2" } + ]; + const findings = [ + { id: "find-A1", proposition: "Proposition A from finding 1", status: "provisional", userDisposition: null, originatingTargetNodeId: "node-A", contributionId: "contrib-A1", sourceObservation: "Source A1" }, + { id: "find-A2", proposition: "Proposition A from finding 2", status: "provisional", userDisposition: "agree", originatingTargetNodeId: "node-A", contributionId: "contrib-A2", sourceObservation: "Source A2" } + ]; + + const result = deriveInvestigationLearning({ situationGraph: graph, contributions, findings }); + + expect(result).toHaveLength(1); + expect(result[0].nodeId).toBe("node-A"); + expect(result[0].questionText).toBe("How do we improve customer retention?"); + expect(result[0].propositions).toHaveLength(2); + expect(result[0].propositions[0].findingId).toBe("find-A1"); + expect(result[0].propositions[0].proposition).toBe("Proposition A from finding 1"); + expect(result[0].propositions[1].findingId).toBe("find-A2"); + expect(result[0].propositions[1].userDisposition).toBe("agree"); + }); +}); + +// === Case 2: no cross-question leakage === + +describe("deriveInvestigationLearning - cross-question isolation", () => { + it("Finding from Contribution B does NOT appear under Question A", () => { + const graph = makeGraph(["node-A", "node-B"]); + const contributions = [ + { id: "contrib-A1", targetNodeId: "node-A", sequence: 1, question: "Q?", answer: "A" }, + { id: "contrib-B1", targetNodeId: "node-B", sequence: 1, question: "Q?", answer: "A" } + ]; + const findings = [ + { id: "find-A", proposition: "Prop A", status: "provisional", userDisposition: null, originatingTargetNodeId: "node-A", contributionId: "contrib-A1", sourceObservation: "S1" }, + { id: "find-B", proposition: "Prop B (should not leak)", status: "provisional", userDisposition: null, originatingTargetNodeId: "node-B", contributionId: "contrib-B1", sourceObservation: "S2" } + ]; + + const result = deriveInvestigationLearning({ situationGraph: graph, contributions, findings }); + + const nodeAResults = result.find(function(r) { return r.nodeId === "node-A"; }); + expect(nodeAResults).not.toBeUndefined(); + expect(nodeAResults.propositions.every(function(p) { return p.findingId !== "find-B"; })).toBe(true); + + const nodeBResults = result.find(function(r) { return r.nodeId === "node-B"; }); + expect(nodeBResults).not.toBeUndefined(); + expect(nodeBResults.propositions.some(function(p) { return p.findingId === "find-B"; })).toBe(true); + }); +}); + +// === Case 3: eligibility rules === + +describe("deriveInvestigationLearning - disposition eligibility", () => { + it("null and agree are eligible; not_quite and not_relevant are excluded", () => { + const graph = makeGraph(["node-A"]); + const contributions = [ + { id: "contrib-null1", targetNodeId: "node-A", sequence: 1, question: "Q?", answer: "A" }, + { id: "contrib-agree2", targetNodeId: "node-A", sequence: 2, question: "Q?", answer: "A" }, + { id: "contrib-nq3", targetNodeId: "node-A", sequence: 3, question: "Q?", answer: "A" }, + { id: "contrib-nr4", targetNodeId: "node-A", sequence: 4, question: "Q?", answer: "A" } + ]; + const findings = [ + { id: "find-null", proposition: "Null prop", status: "provisional", userDisposition: null, originatingTargetNodeId: "node-A", contributionId: "contrib-null1", sourceObservation: "S" }, + { id: "find-agree", proposition: "Agree prop", status: "provisional", userDisposition: "agree", originatingTargetNodeId: "node-A", contributionId: "contrib-agree2", sourceObservation: "S" }, + { id: "find-notquite", proposition: "Not quite prop", status: "provisional", userDisposition: "not_quite", originatingTargetNodeId: "node-A", contributionId: "contrib-nq3", sourceObservation: "S" }, + { id: "find-notrel", proposition: "Not relevant prop", status: "provisional", userDisposition: "not_relevant", originatingTargetNodeId: "node-A", contributionId: "contrib-nr4", sourceObservation: "S" } + ]; + + const result = deriveInvestigationLearning({ situationGraph: graph, contributions, findings }); + + expect(result).toHaveLength(1); + expect(result[0].propositions).toHaveLength(2); + const ids = result[0].propositions.map(function(p) { return p.findingId; }); + expect(ids).toContain("find-null"); + expect(ids).toContain("find-agree"); + expect(ids).not.toContain("find-notquite"); + expect(ids).not.toContain("find-notrel"); + }); +}); + +// === Case 4: unresolved question excluded === + +describe("deriveInvestigationLearning - unresolved exclusion", () => { + it("question not in resolvedNodeIds is omitted even if Contributions/Findings exist for it", () => { + const graph = makeGraph([]); + const contributions = [ + { id: "contrib-X1", targetNodeId: "node-B", sequence: 1, question: "Q?", answer: "A" } + ]; + const findings = [ + { id: "find-X", proposition: "Prop X for unresolved", status: "provisional", userDisposition: null, originatingTargetNodeId: "node-B", contributionId: "contrib-X1", sourceObservation: "S" } + ]; + + const result = deriveInvestigationLearning({ situationGraph: graph, contributions, findings }); + + expect(result).toHaveLength(0); + }); +}); + +// === Case 5: no eligible material === + +describe("deriveInvestigationLearning - empty propositions omitted", () => { + it("investigated question with zero eligible Findings is omitted rather than placeholder", () => { + const graph = makeGraph(["node-A"]); + const contributions = [ + { id: "contrib-N1", targetNodeId: "node-A", sequence: 1, question: "Q?", answer: "A" } + ]; + const findings = [ + { id: "find-nr", proposition: "Not relevant prop", status: "provisional", userDisposition: "not_relevant", originatingTargetNodeId: "node-A", contributionId: "contrib-N1", sourceObservation: "S" } + ]; + + const result = deriveInvestigationLearning({ situationGraph: graph, contributions, findings }); + + expect(result).toHaveLength(0); + }); +}); + +// === Case 6: question text preserved === + +describe("deriveInvestigationLearning - question text preservation", () => { + it("projection preserves canonical question text without model synthesis or rewriting", () => { + const graph = makeGraph(["node-A"]); + const contributions = [ + { id: "contrib-T1", targetNodeId: "node-A", sequence: 1, question: "Q?", answer: "A" } + ]; + const findings = [ + { id: "find-T", proposition: "Canonical prop text", status: "provisional", userDisposition: null, originatingTargetNodeId: "node-A", contributionId: "contrib-T1", sourceObservation: "S" } + ]; + + const result = deriveInvestigationLearning({ situationGraph: graph, contributions, findings }); + + expect(result).toHaveLength(1); + expect(result[0].questionText).toBe("How do we improve customer retention?"); + expect(result[0].propositions[0].proposition).toBe("Canonical prop text"); + }); +}); + +// === Case 7: provenance beats originatingTargetNodeId shortcut === + +describe("deriveInvestigationLearning - provenance authority", () => { + it("explicit Contribution provenance overrides misleading originatingTargetNodeId", () => { + const graph = makeGraph(["node-A", "node-B"]); + // Both nodes have Contributions so they are resolved questions + const contributions = [ + { id: "contrib-A1", targetNodeId: "node-A", sequence: 1, question: "Q?", answer: "A" }, + { id: "contrib-B1", targetNodeId: "node-B", sequence: 1, question: "Q?", answer: "A" } + ]; + // Finding points to contrib-B1 (correct contribution) BUT + // has originatingTargetNodeId pointing at node-A — misleading shortcut + const findings = [ + { id: "find-provenance", proposition: "Must route to B via contributionId not originatingTargetNodeId", status: "provisional", userDisposition: null, originatingTargetNodeId: "node-A", contributionId: "contrib-B1", sourceObservation: "S" }, + { id: "find-a-eligible", proposition: "Node A own finding", status: "provisional", userDisposition: null, originatingTargetNodeId: "node-A", contributionId: "contrib-A1", sourceObservation: "S" } + ]; + + const result = deriveInvestigationLearning({ situationGraph: graph, contributions, findings }); + + // Both nodes appear as resolved questions with propositions + expect(result).toHaveLength(2); + var bResults = result.find(function(r) { return r.nodeId === "node-B"; }); + expect(bResults.propositions.some(function(p) { return p.findingId === "find-provenance"; })).toBe(true); + var aResults = result.find(function(r) { return r.nodeId === "node-A"; }); + expect(aResults).not.toBeUndefined(); + expect(aResults.propositions.some(function(p) { return p.findingId === "find-provenance"; })).toBe(false); + }); +});