Feature/product platform foundation v0.62 #1
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Episode preparation — deterministically prepare a completed focused episode
|
||||
* for future authoritative graph reasoning.
|
||||
*
|
||||
* Pure domain transformation:
|
||||
* SituationGraph + targetNodeId + Contributions + Findings → preparedEpisode
|
||||
*
|
||||
* Purity contract:
|
||||
* - No mutation of inputs (situationGraph, contributions, findings)
|
||||
* - No storage I/O
|
||||
* - No fetch / provider / model calls
|
||||
* - No GraphUpdateProposal generation
|
||||
* - No reasoningState modification
|
||||
*/
|
||||
|
||||
// ── Eligibility constants ──────────────────────────────────
|
||||
|
||||
const ELIGIBLE_DISPOSITIONS = new Set([null, "agree"]);
|
||||
|
||||
const EXCLUDED_DISPOSITIONS = new Set(["not_relevant", "not_quite"]);
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Prepare a completed focused episode for deterministic graph reasoning.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Object} params.situationGraph - Authoritative SituationGraph (read-only)
|
||||
* @param {string} params.targetNodeId - The completed target node ID
|
||||
* @param {Array<Object>} params.contributions - All focused Contributions
|
||||
* @param {Array<Object>} params.findings - All canonical Findings
|
||||
* @returns {Object} preparedEpisode
|
||||
*/
|
||||
export function prepareCompletedEpisode({ situationGraph, targetNodeId, contributions, findings }) {
|
||||
// ── 1. Scope contributions to the completed target ───────
|
||||
|
||||
const scopedContributions = [];
|
||||
|
||||
for (const contrib of contributions) {
|
||||
if (contrib?.targetNodeId === targetNodeId) {
|
||||
scopedContributions.push(contrib);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Order deterministically by sequence ───────────────
|
||||
|
||||
const orderedContributions = scopedContributions.slice().sort((a, b) => {
|
||||
const aSeq = a?.sequence != null ? a.sequence : Infinity;
|
||||
const bSeq = b?.sequence != null ? b.sequence : Infinity;
|
||||
return aSeq - bSeq;
|
||||
});
|
||||
|
||||
// ── 3. Build contribution ID lookup for provenance chain ─
|
||||
|
||||
const contribIds = new Set(orderedContributions.map((c) => c.id));
|
||||
|
||||
// ── 4. Prepare turns (ordered Q/A pairs) ────────────────
|
||||
|
||||
const turns = orderedContributions.map((contrib, idx) => ({
|
||||
contributionId: contrib.id ?? null,
|
||||
sequence: contrib.sequence != null ? contrib.sequence : idx + 1,
|
||||
question: contrib.question ?? "",
|
||||
answer: contrib.answer ?? "",
|
||||
}));
|
||||
|
||||
// ── 5. Bucket Findings by eligibility ───────────────────
|
||||
|
||||
const eligibleCanonicalFindings = [];
|
||||
const excludedFindingProvenance = [];
|
||||
|
||||
for (const finding of findings || []) {
|
||||
if (!finding?.contributionId) continue;
|
||||
|
||||
// Membership authority: only Findings whose contributionId references a scoped Contribution
|
||||
if (!contribIds.has(finding.contributionId)) continue;
|
||||
|
||||
const disposition = finding.userDisposition ?? null;
|
||||
|
||||
const bucketedFinding = {
|
||||
findingId: finding.id ?? null,
|
||||
contributionId: finding.contributionId,
|
||||
proposition: finding.proposition ?? "",
|
||||
sourceObservation: finding.sourceObservation ?? "",
|
||||
disposition,
|
||||
};
|
||||
|
||||
if (ELIGIBLE_DISPOSITIONS.has(disposition)) {
|
||||
eligibleCanonicalFindings.push({
|
||||
...bucketedFinding,
|
||||
endorsement: disposition, // null or "agree"
|
||||
});
|
||||
} else if (EXCLUDED_DISPOSITIONS.has(disposition)) {
|
||||
excludedFindingProvenance.push(bucketedFinding);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Compose prepared episode ─────────────────────────
|
||||
|
||||
return {
|
||||
situationGraph: situationGraph ?? null,
|
||||
targetNodeId,
|
||||
turns,
|
||||
eligibleCanonicalFindings,
|
||||
excludedFindingProvenance,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { prepareCompletedEpisode } from "@/lib/graph/episode-preparation.js";
|
||||
|
||||
// ── Test fixtures ──────────────────────────────────────────
|
||||
|
||||
function makeSituationGraph() {
|
||||
return {
|
||||
centralStatement: "Test scenario",
|
||||
nodes: [{ id: "a-unknown", label: "A Unknown", kind: "unknown", status: "unknown" }, { id: "b-unknown", label: "B Unknown", kind: "unknown", status: "unknown" }],
|
||||
edges: [],
|
||||
activeUnknownNodeId: null,
|
||||
resolvedNodeIds: [],
|
||||
currentSummary: "Baseline summary",
|
||||
};
|
||||
}
|
||||
|
||||
function makeContributions({ ids = ["contrib-0001", "contrib-0002", "contrib-0003"], targetNodeId = "a-unknown", sequenceValues = [1, 2, 3], questions = [], answers = [] } = {}) {
|
||||
return ids.map((id, i) => ({
|
||||
id,
|
||||
targetNodeId: targetNodeId === "both" ? (i < 2 ? "a-unknown" : "b-unknown") : targetNodeId,
|
||||
sequence: sequenceValues[i] ?? i + 1,
|
||||
question: questions[i] ?? `Question ${i + 1}`,
|
||||
answer: answers[i] ?? `Answer ${i + 1}`,
|
||||
observations: [`observation-${id}`],
|
||||
}));
|
||||
}
|
||||
|
||||
function makeFindings(contribIds) {
|
||||
return contribIds.map((contribId, i) => ({
|
||||
id: `finding-find-${String(i).padStart(2, "0")}`,
|
||||
proposition: `Proposition ${i + 1}`,
|
||||
status: "provisional",
|
||||
userDisposition: null,
|
||||
originatingTargetNodeId: contribId.startsWith("find-a") ? "a-unknown" : "b-unknown",
|
||||
contributionId: contribId,
|
||||
sourceObservation: `Source observation ${i + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 1. Target scoping ─────────────────────────────────────
|
||||
|
||||
describe("prepareCompletedEpisode — target scoping", () => {
|
||||
it("only includes Contributions for the completed target", () => {
|
||||
const graph = makeSituationGraph();
|
||||
const contributions = [
|
||||
{ id: "contrib-a1", targetNodeId: "a-unknown", sequence: 1, question: "Q1?", answer: "A1" },
|
||||
{ id: "contrib-b1", targetNodeId: "b-unknown", sequence: 1, question: "Q2?", answer: "A2" },
|
||||
{ id: "contrib-a2", targetNodeId: "a-unknown", sequence: 2, question: "Q3?", answer: "A3" },
|
||||
];
|
||||
const findings = [
|
||||
{ id: "f1", proposition: "P1", status: "provisional", userDisposition: null, originatingTargetNodeId: "a-unknown", contributionId: "contrib-a1", sourceObservation: "S1" },
|
||||
{ id: "f2", proposition: "P2", status: "provisional", userDisposition: null, originatingTargetNodeId: "b-unknown", contributionId: "contrib-b1", sourceObservation: "S2" },
|
||||
];
|
||||
|
||||
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
||||
|
||||
expect(result.targetNodeId).toBe("a-unknown");
|
||||
expect(result.turns).toHaveLength(2);
|
||||
expect(result.turns.map((t) => t.contributionId)).toEqual(["contrib-a1", "contrib-a2"]);
|
||||
// Findings from non-scoped Contributions must not appear in eligible or excluded
|
||||
const allBucketedIds = new Set([...result.eligibleCanonicalFindings, ...result.excludedFindingProvenance].map((f) => f.findingId));
|
||||
expect(allBucketedIds.has("f2")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. Ordering ────────────────────────────────────────────
|
||||
|
||||
describe("prepareCompletedEpisode — ordering", () => {
|
||||
it("orders contributions by sequence ascending regardless of input order", () => {
|
||||
const graph = makeSituationGraph();
|
||||
const contributions = [
|
||||
{ id: "contrib-0003", targetNodeId: "a-unknown", sequence: 3, question: "Q3?", answer: "A3" },
|
||||
{ id: "contrib-0001", targetNodeId: "a-unknown", sequence: 1, question: "Q1?", answer: "A1" },
|
||||
{ id: "contrib-0002", targetNodeId: "a-unknown", sequence: 2, question: "Q2?", answer: "A2" },
|
||||
];
|
||||
const findings = [];
|
||||
|
||||
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
||||
|
||||
expect(result.turns.map((t) => t.sequence)).toEqual([1, 2, 3]);
|
||||
expect(result.turns[0].contributionId).toBe("contrib-0001");
|
||||
expect(result.turns[1].contributionId).toBe("contrib-0002");
|
||||
expect(result.turns[2].contributionId).toBe("contrib-0003");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. Eligibility ─────────────────────────────────────────
|
||||
|
||||
describe("prepareCompletedEpisode — eligibility bucketing", () => {
|
||||
it("routes null and agree to eligibleCanonicalFindings", () => {
|
||||
const graph = makeSituationGraph();
|
||||
const contributions = makeContributions({ ids: ["contrib-e1"], targetNodeId: "a-unknown" });
|
||||
const findings = [
|
||||
{ id: "find-null", proposition: "Null prop", status: "provisional", userDisposition: null, originatingTargetNodeId: "a-unknown", contributionId: "contrib-e1", sourceObservation: "Source null" },
|
||||
{ id: "find-agree", proposition: "Agree prop", status: "provisional", userDisposition: "agree", originatingTargetNodeId: "a-unknown", contributionId: "contrib-e1", sourceObservation: "Source agree" },
|
||||
];
|
||||
|
||||
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
||||
|
||||
expect(result.eligibleCanonicalFindings).toHaveLength(2);
|
||||
expect(result.excludedFindingProvenance).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("routes not_relevant and not_quite to excludedFindingProvenance", () => {
|
||||
const graph = makeSituationGraph();
|
||||
const contributions = makeContributions({ ids: ["contrib-x1", "contrib-x2"], targetNodeId: "a-unknown" });
|
||||
const findings = [
|
||||
{ id: "find-notrel", proposition: "Not rel prop", status: "provisional", userDisposition: "not_relevant", originatingTargetNodeId: "a-unknown", contributionId: "contrib-x1", sourceObservation: "Source NR" },
|
||||
{ id: "find-notquite", proposition: "Not quite prop", status: "provisional", userDisposition: "not_quite", originatingTargetNodeId: "a-unknown", contributionId: "contrib-x2", sourceObservation: "Source NQ" },
|
||||
];
|
||||
|
||||
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
||||
|
||||
expect(result.eligibleCanonicalFindings).toHaveLength(0);
|
||||
expect(result.excludedFindingProvenance).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 4. Silence is not agreement ───────────────────────────
|
||||
|
||||
describe("prepareCompletedEpisode — silence is not agreement", () => {
|
||||
it("preserves null as distinguishable from agree in endorsement field", () => {
|
||||
const graph = makeSituationGraph();
|
||||
const contributions = makeContributions({ ids: ["contrib-s1", "contrib-s2"], targetNodeId: "a-unknown" });
|
||||
const findings = [
|
||||
{ id: "find-null", proposition: "Silence prop", status: "provisional", userDisposition: null, originatingTargetNodeId: "a-unknown", contributionId: "contrib-s1", sourceObservation: "Source silence" },
|
||||
{ id: "find-agree", proposition: "Agree prop", status: "provisional", userDisposition: "agree", originatingTargetNodeId: "a-unknown", contributionId: "contrib-s2", sourceObservation: "Source agree" },
|
||||
];
|
||||
|
||||
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
||||
|
||||
const nullFinding = result.eligibleCanonicalFindings.find((f) => f.findingId === "find-null");
|
||||
const agreeFinding = result.eligibleCanonicalFindings.find((f) => f.findingId === "find-agree");
|
||||
|
||||
expect(nullFinding.endorsement).toBe(null);
|
||||
expect(agreeFinding.endorsement).toBe("agree");
|
||||
expect(nullFinding.endorsement !== agreeFinding.endorsement).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 5. Correction provenance ──────────────────────────────
|
||||
|
||||
describe("prepareCompletedEpisode — correction provenance", () => {
|
||||
it("preserves distinct proposition and sourceObservation for corrected eligible Finding", () => {
|
||||
const graph = makeSituationGraph();
|
||||
const contributions = makeContributions({ ids: ["contrib-c1"], targetNodeId: "a-unknown" });
|
||||
const findings = [
|
||||
{
|
||||
id: "find-corrected",
|
||||
proposition: "Corrected canonical text — user fixed the observation",
|
||||
status: "provisional",
|
||||
userDisposition: null,
|
||||
originatingTargetNodeId: "a-unknown",
|
||||
contributionId: "contrib-c1",
|
||||
sourceObservation: "Original model observation that was corrected",
|
||||
},
|
||||
];
|
||||
|
||||
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
||||
|
||||
expect(result.eligibleCanonicalFindings).toHaveLength(1);
|
||||
const eligible = result.eligibleCanonicalFindings[0];
|
||||
expect(eligible.proposition).toBe("Corrected canonical text — user fixed the observation");
|
||||
expect(eligible.sourceObservation).toBe("Original model observation that was corrected");
|
||||
expect(eligible.proposition).not.toBe(eligible.sourceObservation);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 6. Excluded provenance ────────────────────────────────
|
||||
|
||||
describe("prepareCompletedEpisode — excluded provenance", () => {
|
||||
it("retains proposition, sourceObservation, disposition, contributionId for not_relevant Finding", () => {
|
||||
const graph = makeSituationGraph();
|
||||
const contributions = makeContributions({ ids: ["contrib-ep1"], targetNodeId: "a-unknown" });
|
||||
const findings = [
|
||||
{ id: "find-notrel", proposition: "Not relevant proposition text", status: "provisional", userDisposition: "not_relevant", originatingTargetNodeId: "a-unknown", contributionId: "contrib-ep1", sourceObservation: "Immutable original observation" },
|
||||
];
|
||||
|
||||
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
||||
|
||||
expect(result.excludedFindingProvenance).toHaveLength(1);
|
||||
const excluded = result.excludedFindingProvenance[0];
|
||||
expect(excluded.findingId).toBe("find-notrel");
|
||||
expect(excluded.proposition).toBe("Not relevant proposition text");
|
||||
expect(excluded.sourceObservation).toBe("Immutable original observation");
|
||||
expect(excluded.disposition).toBe("not_relevant");
|
||||
expect(excluded.contributionId).toBe("contrib-ep1");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 7. Membership authority ───────────────────────────────
|
||||
|
||||
describe("prepareCompletedEpisode — membership authority", () => {
|
||||
it("excludes Findings whose contributionId does not reference a scoped Contribution even if originatingTargetNodeId matches", () => {
|
||||
const graph = makeSituationGraph();
|
||||
// contrib-xtra targets "b-unknown" so it is NOT part of the scoped episode for target A
|
||||
const contributions = [
|
||||
{ id: "contrib-a1", targetNodeId: "a-unknown", sequence: 1, question: "Q?", answer: "A" },
|
||||
{ id: "contrib-xtra", targetNodeId: "b-unknown", sequence: 1, question: "Q2?", answer: "A2" },
|
||||
];
|
||||
// Finding references contrib-xtra which is not scoped to target A.
|
||||
// Even though originatingTargetNodeId === "a-unknown", membership is determined by contributionId.
|
||||
const findings = [
|
||||
{ id: "find-orphan", proposition: "Orphan prop", status: "provisional", userDisposition: null, originatingTargetNodeId: "a-unknown", contributionId: "contrib-xtra", sourceObservation: "Orig obs" },
|
||||
];
|
||||
|
||||
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
||||
|
||||
// Nothing should be bucketed because the only Finding references a non-scoped Contribution
|
||||
expect(result.eligibleCanonicalFindings).toHaveLength(0);
|
||||
expect(result.excludedFindingProvenance).toHaveLength(0);
|
||||
|
||||
const allBucketed = [...result.eligibleCanonicalFindings, ...result.excludedFindingProvenance];
|
||||
expect(allBucketed.some((f) => f.findingId === "find-orphan")).toBe(false);
|
||||
});
|
||||
|
||||
it("includes scoped Finding as eligible when its contributionId references a scoped Contribution", () => {
|
||||
const graph = makeSituationGraph();
|
||||
const contributions = [
|
||||
{ id: "contrib-val1", targetNodeId: "a-unknown", sequence: 1, question: "Q?", answer: "A" },
|
||||
];
|
||||
const findings = [
|
||||
{ id: "find-valid2", proposition: "Valid prop 2", status: "provisional", userDisposition: null, originatingTargetNodeId: "x-unknown", contributionId: "contrib-val1", sourceObservation: "Orig valid" },
|
||||
];
|
||||
|
||||
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
||||
|
||||
// Even though originatingTargetNodeId !== targetNodeId, the Contribution provenance chain authorizes inclusion
|
||||
expect(result.eligibleCanonicalFindings).toHaveLength(1);
|
||||
expect(result.eligibleCanonicalFindings[0].findingId).toBe("find-valid2");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 8. Purity ──────────────────────────────────────────────
|
||||
|
||||
describe("prepareCompletedEpisode — purity", () => {
|
||||
it("does not mutate situationGraph, contributions, or findings", () => {
|
||||
const graph = makeSituationGraph();
|
||||
const contributions = [
|
||||
{ id: "contrib-p1", targetNodeId: "a-unknown", sequence: 1, question: "Q?", answer: "A" },
|
||||
];
|
||||
const findings = [
|
||||
{ id: "find-p1", proposition: "Prop P", status: "provisional", userDisposition: null, originatingTargetNodeId: "a-unknown", contributionId: "contrib-p1", sourceObservation: "Source P" },
|
||||
];
|
||||
|
||||
// Snapshot originals before call
|
||||
const graphSnapshot = JSON.parse(JSON.stringify(graph));
|
||||
const contribsSnapshot = contributions.map((c) => JSON.parse(JSON.stringify(c)));
|
||||
const findingsSnapshot = findings.map((f) => JSON.parse(JSON.stringify(f)));
|
||||
|
||||
prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
||||
|
||||
expect(graph).toEqual(graphSnapshot);
|
||||
expect(contributions).toEqual(contribsSnapshot);
|
||||
expect(findings).toEqual(findingsSnapshot);
|
||||
|
||||
// Verify no new properties were added to inputs
|
||||
expect(Object.keys(graph)).toEqual(Object.keys(graphSnapshot));
|
||||
expect(Object.keys(contributions[0])).toEqual(Object.keys(contribsSnapshot[0]));
|
||||
expect(Object.keys(findings[0])).toEqual(Object.keys(findingsSnapshot[0]));
|
||||
});
|
||||
|
||||
it("returns a new object that is independent from inputs", () => {
|
||||
const graph = makeSituationGraph();
|
||||
const contributions = [
|
||||
{ id: "contrib-i1", targetNodeId: "a-unknown", sequence: 1, question: "Q?", answer: "A" },
|
||||
];
|
||||
const findings = [];
|
||||
|
||||
const result = prepareCompletedEpisode({ situationGraph: graph, targetNodeId: "a-unknown", contributions, findings });
|
||||
|
||||
expect(result).not.toBe(graph);
|
||||
expect(result).not.toBe(contributions);
|
||||
expect(result).not.toBe(findings);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user