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); }); });