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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user