Files
confidence-engine/tests/graph/episode-reasoning-input.test.js

378 lines
16 KiB
JavaScript

import { describe, expect, it } from "vitest";
import { buildEpisodeAwareGraphPrompt } from "@/lib/graph/prompt-builder-episode.js";
import { buildGraphUpdatePrompt } from "@/lib/graph/prompt-builder.js";
import { makeEdge, makeGraph, makeNode } from "@/lib/graph/schema.js";
// ── Test fixture: multi-turn prepared episode ───────────────
function makeEpisodeFixture() {
const situationGraph = makeGraph({
centralStatement: "Market share declining in core segment",
nodes: [
makeNode({
id: "core-shrink-unknown",
label: "Core segment shrink rate",
description: "Need to determine if core market is genuinely shrinking or if this is measurement artifact",
kind: "unknown",
status: "unknown",
confidence: "medium",
}),
makeNode({
id: "competitor-move-obs",
label: "Competitor pricing move",
description: "Competitor X reduced prices by 15%",
kind: "observation",
status: "supported",
confidence: "high",
}),
],
edges: [
makeEdge({
id: "e1",
fromNodeId: "competitor-move-obs",
toNodeId: "core-shrink-unknown",
relationship: "supports",
confidence: "medium",
description: "Observation informs unknown",
}),
],
activeUnknownNodeId: "core-shrink-unknown",
resolvedNodeIds: [],
currentSummary: "Nodes: 1 observation, 1 unknown | Edges: 1 total | Unknowns: 1 unresolved",
});
const turns = [
{
contributionId: "contrib-a01",
sequence: 1,
question: "Has the core market volume actually decreased, or is it a measurement artifact?",
answer: "Our internal data shows core volume dropped from 12,400 to 11,800 units over Q3.",
},
{
contributionId: "contrib-a02",
sequence: 2,
question: "Does Competitor X's pricing explain the full decline?",
answer: "Competitor X's price cut correlates with a 40% shift in our B2B accounts. We estimate ~75% of the decline maps to this competitive move.",
},
{
contributionId: "contrib-b01",
sequence: 3,
question: "Is core quality perception affected?",
answer: "We have no evidence of quality degradation — returns rate is stable at 0.3%.",
},
];
const eligibleCanonicalFindings = [
{
findingId: "find-core-drop",
contributionId: "contrib-a01",
proposition: "Core market volume declined from 12,400 to 11,800 units in Q3",
sourceObservation: "Raw internal data shows drop from 12,400 to 11,800 units",
endorsement: null, // working premise — not explicit agreement
},
{
findingId: "find-competitive-shift",
contributionId: "contrib-a02",
proposition: "Competitor X pricing accounts for ~75% of core decline via B2B account shift",
sourceObservation: "Model inference linking price to volume",
endorsement: "agree", // explicitly endorsed by user
},
{
findingId: "find-quality-stable",
contributionId: "contrib-b01",
proposition: "Core quality perception remains stable — returns rate unchanged at 0.3%",
sourceObservation: "Original observation from Q2 analysis",
endorsement: null, // working premise — not explicit agreement
},
];
const excludedFindingProvenance = [
{
findingId: "find-excluded-supply",
contributionId: "contrib-a01",
proposition: "SUPPLY-chain disruption partially contributed to volume loss (excluded from current reasoning)",
sourceObservation: "Initial hypothesis later determined irrelevant",
disposition: "not_relevant",
},
];
return { situationGraph, targetNodeId: "core-shrink-unknown", turns, eligibleCanonicalFindings, excludedFindingProvenance };
}
// ── Helper: extract provider-active content (everything before excluded section) ──
function getProviderContent(prompt) {
const excludedSection = prompt.indexOf("## Excluded Findings");
return excludedSection > 0 ? prompt.slice(0, excludedSection) : prompt;
}
// ====================================================================
// Case 1: Ordered multi-turn evidence
// ====================================================================
describe("Episode reasoning input — ordered multi-turn evidence", () => {
const prompt = buildEpisodeAwareGraphPrompt({ episode: makeEpisodeFixture() });
it("includes all three turns in sequence order", () => {
expect(prompt).toContain("Turn 1 [contributionId: contrib-a01]");
expect(prompt).toContain("Turn 2 [contributionId: contrib-a02]");
expect(prompt).toContain("Turn 3 [contributionId: contrib-b01]");
// Verify ordering: Turn 1 appears before Turn 2 before Turn 3
const t1 = prompt.indexOf("Turn 1 [contributionId: contrib-a01]");
const t2 = prompt.indexOf("Turn 2 [contributionId: contrib-a02]");
const t3 = prompt.indexOf("Turn 3 [contributionId: contrib-b01]");
expect(t1).toBeLessThan(t2);
expect(t2).toBeLessThan(t3);
});
it("includes verbatim answers from each turn in provider-active content", () => {
const providerContent = getProviderContent(prompt);
expect(providerContent).toContain("core volume dropped from 12,400 to 11,800 units over Q3");
expect(providerContent).toContain("40% shift in our B2B accounts");
expect(providerContent).toContain("returns rate is stable at 0.3%");
});
it("includes ordered investigation turns header", () => {
expect(prompt).toContain("### Ordered Investigation Turns (ordered context)");
});
});
// ====================================================================
// Case 2: Raw evidence distinction
// ====================================================================
describe("Episode reasoning input — raw evidence distinction", () => {
const prompt = buildEpisodeAwareGraphPrompt({ episode: makeEpisodeFixture() });
const providerContent = getProviderContent(prompt);
it("clearly labels answers as verbatim (raw evidence)", () => {
expect(providerContent).toContain("Verbatim Answer");
expect(providerContent).toContain("not raw user statements");
});
it("labels eligible findings as canonical propositions derived from evidence", () => {
expect(providerContent).toContain("Eligible Canonical Findings Derived During Investigation");
expect(providerContent).toContain("canonical propositions derived from the investigation turns above");
});
it("does NOT conflate Finding proposition with raw user statement", () => {
// The prompt must distinguish, not blur:
expect(providerContent).not.toContain("user said");
expect(providerContent).not.toContain("raw evidence" + "prop"); // would blur the distinction
// And should clearly separate the two concepts:
expect(providerContent).toContain("Proposition:");
expect(providerContent).toContain("Verbatim Answer:");
});
it("marks source of each proposition as derived, not raw", () => {
expect(providerContent).toContain("derived from the investigation");
});
});
// ====================================================================
// Case 3: Null versus agree distinction
// ====================================================================
describe("Episode reasoning input — null versus agree endorsement", () => {
const prompt = buildEpisodeAwareGraphPrompt({ episode: makeEpisodeFixture() });
const providerContent = getProviderContent(prompt);
it("null is labeled as working premise, not explicit agreement", () => {
// Both findings with null endorsement must show the working premise label
expect(providerContent).toContain("null (working premise, not explicit agreement)");
});
it("agree is labeled as explicitly endorsed proposition", () => {
expect(providerContent).toContain("agree (explicitly endorsed proposition)");
});
it("both semantics are distinguishable in provider-active content", () => {
// The two distinct labels must appear
const nullLabel = "null (working premise, not explicit agreement)";
const agreeLabel = "agree (explicitly endorsed proposition)";
expect(providerContent).toContain(nullLabel);
expect(providerContent).toContain(agreeLabel);
// And the endorsement semantics section exists:
expect(providerContent).toContain("### Endorsement Semantics");
});
});
// ====================================================================
// Case 4: Excluded evidence absent from provider-active content
// ====================================================================
describe("Episode reasoning input — excluded evidence absent", () => {
const prompt = buildEpisodeAwareGraphPrompt({ episode: makeEpisodeFixture() });
const providerContent = getProviderContent(prompt);
it("excluded proposition does NOT appear in provider-active content", () => {
// The excluded finding's proposition must be absent from the provider-visible section
expect(providerContent).not.toContain("SUPPLY-chain disruption");
});
it("prepared episode retains excluded provenance (data layer)", () => {
// Excluded findings are preserved in the prepared episode contract,
// they just don't appear in provider-active reasoning content.
const episode = makeEpisodeFixture();
expect(episode.excludedFindingProvenance).toHaveLength(1);
expect(episode.excludedFindingProvenance[0].proposition).toContain("SUPPLY-chain disruption");
});
it("excluded proposition absent from both prompt and provider content", () => {
// Since excluded findings are intentionally NOT serialized into the prompt,
// the excluded proposition is absent everywhere in the generated prompt.
expect(prompt).not.toContain("SUPPLY-chain disruption");
const providerContent = getProviderContent(prompt);
expect(providerContent).not.toContain("SUPPLY-chain disruption");
});
});
// ====================================================================
// Case 5: Corrected proposition — provider receives corrected form
// ====================================================================
describe("Episode reasoning input — corrected proposition", () => {
function makeCorrectedEpisode() {
const situationGraph = makeGraph({
centralStatement: "Baseline scenario",
nodes: [makeNode({ id: "a-unknown", label: "A Unknown", kind: "unknown", status: "unknown" })],
edges: [],
activeUnknownNodeId: "a-unknown",
resolvedNodeIds: [],
currentSummary: "Test",
});
return {
situationGraph,
targetNodeId: "a-unknown",
turns: [
{ contributionId: "contrib-c01", sequence: 1, question: "Is A true?", answer: "Yes." },
],
eligibleCanonicalFindings: [
{
findingId: "find-corrected",
contributionId: "contrib-c01",
proposition: "Corrected canonical text — user fixed the observation",
sourceObservation: "Original model observation that was incorrect",
endorsement: null,
},
],
excludedFindingProvenance: [],
};
}
const prompt = buildEpisodeAwareGraphPrompt({ episode: makeCorrectedEpisode() });
const providerContent = getProviderContent(prompt);
it("provider-active evidence uses the corrected canonical proposition", () => {
expect(providerContent).toContain("Corrected canonical text — user fixed the observation");
});
it("sourceObservation does NOT need to appear in provider-active content", () => {
// The prompt builder intentionally omits sourceObservation from active reasoning
// because corrected proposition is the current canonical authority.
expect(providerContent).not.toContain("Original model observation that was incorrect");
});
it("corrected Finding appears under eligible findings, not excluded", () => {
expect(prompt).toContain("## Eligible Canonical Findings Derived During Investigation");
});
});
// ====================================================================
// Case 6: Existing GraphUpdateProposal contract still targeted
// ====================================================================
describe("Episode reasoning input — proposal contract preserved", () => {
const prompt = buildEpisodeAwareGraphPrompt({ episode: makeEpisodeFixture() });
it("required JSON fields are present", () => {
expect(prompt).toContain("addedNodes");
expect(prompt).toContain("updatedNodes");
expect(prompt).toContain("addedEdges");
expect(prompt).toContain("removedEdgeIds");
expect(prompt).toContain("resolvedUnknownNodeIds");
expect(prompt).toContain("affectedNodeIds");
expect(prompt).toContain("selectedQuestion");
expect(prompt).toContain("answerMeaning");
expect(prompt).toContain("structuralActionRequired");
});
it("enum values present", () => {
expect(prompt).toContain("observation | reported_claim | metric | state | transition | relationship | assumption | unknown | conclusion | option");
expect(prompt).toContain("known | unknown | provisional | supported | weakened | contradicted | resolved");
});
it("proposal rules are intact", () => {
expect(prompt).toContain("Propose changes only. Never return a replacement graph.");
expect(prompt).toContain("answerMeaning.userSupportedMeaning must state only what the user's answer directly supports.");
expect(prompt).toContain("MUST express its effect through structural mutation");
});
it("Decision Sufficiency Rule is present", () => {
expect(prompt).toContain("## Decision Sufficiency Rule");
});
it("output contract reminder is present", () => {
expect(prompt).toContain("Return one JSON object only");
expect(prompt).toContain("Never include a full graph");
});
});
// ====================================================================
// Case 7: Single-turn regression — existing path intact
// ====================================================================
describe("Episode reasoning input — single-turn regression", () => {
it("buildGraphUpdatePrompt still accepts previousQuestion/answer and produces same structure", () => {
const prompt = buildGraphUpdatePrompt({
situationGraph: makeGraph({
centralStatement: "test",
nodes: [makeNode({ id: "n1", label: "A", kind: "unknown", status: "unknown" })],
edges: [],
activeUnknownNodeId: "n1",
resolvedNodeIds: [],
currentSummary: "test graph",
}),
previousQuestion: "What is A?",
answer: "It's B.",
});
// Essential single-turn sections must still be present:
expect(prompt).toContain("## Previous Selected Question");
expect(prompt).toContain("What is A?");
expect(prompt).toContain("## User Answer");
expect(prompt).toContain("It's B.");
// GraphUpdateProposal contract fields must still be present:
expect(prompt).toContain("addedNodes");
expect(prompt).toContain("updatedNodes");
expect(prompt).toContain("answerMeaning");
// Episode-aware sections must NOT appear in single-turn path:
expect(prompt).not.toContain("## Ordered Investigation Turns");
expect(prompt).not.toContain("## Eligible Canonical Findings Derived During Investigation");
expect(prompt).not.toContain("Completed Focused Investigation Evidence");
});
it("buildGraphUpdatePrompt still contains existing rules", () => {
const prompt = buildGraphUpdatePrompt({
situationGraph: makeGraph({
centralStatement: "test",
nodes: [makeNode({ id: "n1", label: "A", kind: "unknown", status: "unknown" })],
edges: [],
activeUnknownNodeId: "n1",
resolvedNodeIds: [],
currentSummary: "test graph",
}),
previousQuestion: "Q?",
answer: "A.",
});
// These are established rules that must survive unchanged:
expect(prompt).toContain("MUST express its effect through structural mutation");
expect(prompt).toContain("rule #6");
expect(prompt).toContain("Do not add duplicate unknowns");
});
});