feat(confidence-engine): deriveInvestigationLearning deterministic learning projection from canonical provenance

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.
This commit is contained in:
2026-10-02 08:18:19 +01:00
parent 30648ca5b3
commit b448f522f6
2 changed files with 264 additions and 2 deletions
@@ -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);
});
});