378 lines
16 KiB
JavaScript
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");
|
|
});
|
|
});
|