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.
187 lines
9.7 KiB
JavaScript
187 lines
9.7 KiB
JavaScript
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);
|
|
});
|
|
});
|