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
+78 -2
View File
@@ -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<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;
}
@@ -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);
});
});