From 6b77e32771dd4ba7836a0ce28b2fc22eceda7485 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 1 Sep 2026 06:59:04 +0100 Subject: [PATCH] feat(confidence-engine): accept completed episode reasoning input --- lib/graph/prompt-builder-episode.js | 214 +++++++++++ tests/graph/episode-reasoning-input.test.js | 377 ++++++++++++++++++++ 2 files changed, 591 insertions(+) create mode 100644 lib/graph/prompt-builder-episode.js create mode 100644 tests/graph/episode-reasoning-input.test.js diff --git a/lib/graph/prompt-builder-episode.js b/lib/graph/prompt-builder-episode.js new file mode 100644 index 0000000..2e42d28 --- /dev/null +++ b/lib/graph/prompt-builder-episode.js @@ -0,0 +1,214 @@ +/** + * Episode-aware graph reasoning prompt builder. + * + * Takes a deterministically prepared completed episode and produces + * a prompt that frames the reasoning task as "reconsider the authoritative + * SituationGraph using the completed focused investigation as structured evidence" + * rather than "update the graph from this answer". + * + * This is intentionally a separate export from buildGraphUpdatePrompt to + * preserve the existing single-turn contract and avoid any ambiguity. + */ + +const DEFAULT_PROMPT_VERSION = "v0.4"; + +/** + * Build a prompt for episode-aware authoritative graph reconsideration. + * + * The prepared episode structure is consumed by deterministic preparation, + * not by this builder. This builder only serializes the proven material. + * + * @param {Object} params + * @param {Object} params.episode - Prepared completed episode from prepareCompletedEpisode() + * @param {string} [params.promptVersion] - Prompt schema version (defaults to v0.4) + * @returns {string} structured reasoning prompt + */ +export function buildEpisodeAwareGraphPrompt({ episode, promptVersion = DEFAULT_PROMPT_VERSION }) { + const { situationGraph, targetNodeId, turns, eligibleCanonicalFindings, excludedFindingProvenance } = episode; + + // ── Serialize the current authoritative graph ──────────── + + const situationGraphSection = typeof situationGraph === "string" ? situationGraph : JSON.stringify(situationGraph, null, 2); + + // ── Serialize ordered turns with eligible evidence per turn ── + + const turnsSection = turns + .map( + (turn) => { + const rawEvidence = `\n Question: ${turn.question}\n Verbatim Answer: ${turn.answer}`; + + const eligibleForTurn = eligibleCanonicalFindings.filter((f) => f.contributionId === turn.contributionId); + + let evidenceSection = ""; + if (eligibleForTurn.length > 0) { + const evidenceItems = eligibleForTurn.map( + (f) => ` - Proposition: ${f.proposition} | Endorsement: ${f.endorsement == null ? "null (working premise, not explicit agreement)" : "agree (explicitly endorsed proposition)"}` + ); + evidenceSection = "\n Eligible Canonical Findings from this turn:\n" + evidenceItems.join("\n"); + } + + return `Turn ${turn.sequence} [contributionId: ${turn.contributionId}]${rawEvidence}${evidenceSection}`; + } + ) + .join("\n\n"); + + // ── Serialize eligible findings for provider reasoning ─── + + const eligibleFindingsList = eligibleCanonicalFindings.map( + (f) => ` - [FindingId: ${f.findingId}] Contribution: ${f.contributionId} | Proposition: ${f.proposition} | Endorsement: ${f.endorsement == null ? "null (working premise, not explicit agreement)" : "agree (explicitly endorsed proposition)"}` + ); + + // ── Assemble the episode-aware prompt ──────────────────── + + return `You are proposing a graph update for Confidence Engine ${promptVersion}. + +Return exactly one JSON object matching the GraphUpdate contract. +Return JSON only. Do not include markdown, explanation, or any text before or after the JSON object. + +## Current Situation Graph (authoritative — may reflect pre-investigation state at Done) +${situationGraphSection} + +## Target Node Being Reconsidered +${targetNodeId} + +## Completed Focused Investigation Evidence + +A focused investigation was completed on this target. The following evidence is structured in turn order. Each turn captures a discrete investigative question and its verbatim answer. Turn order reflects the actual sequence of inquiry. + +### Ordered Investigation Turns (ordered context) +${turnsSection} + +### Eligible Canonical Findings Derived During Investigation +The following findings are canonical propositions derived from the investigation turns above, not raw user statements: +${eligibleFindingsList.length > 0 ? eligibleFindingsList.join("\n") : " (none derived during this episode)"} + +### Endorsement Semantics — Read Carefully +- null → working premise: NOT explicit agreement; eligible for your reasoning but not endorsed +- agree → explicitly endorsed proposition by the user at the proposition level +These are proposition-level endorsements. They are evidence for global reasoning and never directly mutate authoritative graph state. + +### Graph Reconsideration Task +Reconsider the authoritative SituationGraph using the completed focused investigation as structured evidence: +1. Evaluate what the ordered turns — their questions, verbatim answers, and eligible canonical findings with endorsement status — collectively imply about the graph. +2. The situation graph may still reflect pre-investigation state; you must reconcile all episode-derived material before proposing changes. +3. Produce a GraphUpdateProposal describing the minimal set of structural changes implied by this evidence across the completed episode. + +## Allowed Node Kinds +observation | reported_claim | metric | state | transition | relationship | assumption | unknown | conclusion | option + +## Allowed Node Statuses +known | unknown | provisional | supported | weakened | contradicted | resolved + +## Allowed Edge Relationships +supports | weakens | contradicts | depends_on | causes | may_cause | measures | compares_with | updates | contained_in | other + +## Allowed Confidence Values +low | medium | high + +## Required JSON Field Names +The JSON object must contain exactly these top-level fields: +- addedNodes +- updatedNodes +- addedEdges +- removedEdgeIds +- resolvedUnknownNodeIds +- affectedNodeIds +- selectedQuestion +- answerMeaning +- structuralActionRequired + +## Required Shapes +- addedNodes: array of nodes using these exact keys: id, label, description, kind, status, confidence, value, unit, evidenceIds, dependsOn, affects, parentId, childIds +- updatedNodes: array of node updates using these exact keys: nodeId, previousStatus, newStatus, previousValue, newValue, reason +- addedEdges: array of edges using these exact keys: id, fromNodeId, toNodeId, relationship, confidence, description +- removedEdgeIds: array of strings +- resolvedUnknownNodeIds: array of strings +- affectedNodeIds: array of strings +- selectedQuestion: either null or an object using these exact keys: nodeId, question, reason +- answerMeaning: either null or an object using these exact keys: userSupportedMeaning, possibleInference, supportCategory, resolutionGuidance +- structuralActionRequired: boolean (required when userSupportedMeaning is populated) + +## Proposal Rules +1. Propose changes only. Never return a replacement graph. +2. Preserve unrelated nodes and edges by omitting them from the proposal. +3. Reference existing node IDs when updating an existing concept. +4. Use addedNodes only for genuinely new concepts. +5. Resolve the answered unknown first when the answer supports it. +6. If answerMeaning.userSupportedMeaning contains consequential information or unresolved uncertainty that is not already represented in the graph, you MUST express its effect through structural mutation. This may be an update/refinement of existing structure, resolution of an existing unknown, a genuinely new unknown, or a justified relationship. answerMeaning alone is not sufficient for a successful proposal. +7. Add new unknown nodes only when the answer introduces a new decision, claim, object, measure, dependency, or unresolved term directly relevant to the case. +8. Add at most 3 new unknown nodes. +9. Every new unknown must be directly traceable to the user's answer and its description must state why that uncertainty matters. +9a. In the description of every new unknown, explicitly include a short why-it-matters clause using wording such as because, so that, needed to decide, or matters because. +10. Do not add broad generic discovery questions. +11. Do not add duplicate unknowns. +12. Do not expand unrelated branches. +13. Propagate only through explicit dependencies or relationships already present in the graph, except for the minimal new edges needed to connect validated new unknowns to the relevant answer-derived decision or context node. +13a. For every new unknown node, include at least one added edge that connects it to an existing updated/resolved node or to a newly added non-unknown node introduced from the answer. +14. Do not invent evidence. +15. Do not create unsupported causal edges. +16. When your proposal adds one or more new unresolved unknowns (status !== 'resolved'), you MUST include a selectedQuestion identifying one of those as a candidate unknown node. The engine validates your candidate and retains deterministic prerequisite ordering, fallback selection, and formulation authority; prefer nodes with no unresolved depends_on prerequisites from same-proposal additions. Your candidate does not need to be the highest-scoring unknown — it only needs to be a valid unresolved unknown that exists in the graph or in addedNodes. +17. selectedQuestion.nodeId must reference an unknown node that remains unresolved after applying this same proposal and that exists either already in the graph or in addedNodes. If the proposal resolves all consequential unknowns, selectedQuestion must be null. +18. selectedQuestion.question must be one narrow non-compound question about that one unknown. +19. Do not prioritise downstream implementation, pricing, optimisation, or speculative branches ahead of prerequisite definitions, actors, success criteria, constraints, measures, or terminology. +20. Return selectedQuestion as null only when no consequential unresolved unknown remains. +21. Use empty arrays when there are no changes in a category. +22. Never return null array entries. +23. Never use unknown enum values. +24. Do not change existing IDs. +25. Do not replace the whole graph, and do not restate unchanged graph content inside the proposal. +26. answerMeaning.userSupportedMeaning must state only what the user's answer directly supports. +27. Put any stronger interpretation in answerMeaning.possibleInference, not in userSupportedMeaning. +28. When answerMeaning is present, populate supportCategory with one of the allowed values whenever the user's meaning fits an existing category. Use other when none of the protected categories applies. Do not leave supportCategory null merely because the wording is uncertain. +29. If the answer is conditional or qualified, preserve that qualification explicitly in userSupportedMeaning. +30. If the answer says the user is unsure or does not resolve the distinction, state that uncertainty directly in userSupportedMeaning. +31. If the answer explicitly states a hard constraint, state that directly in userSupportedMeaning. +32. Populate resolutionGuidance when the user's meaning genuinely implies must_remain_unresolved, may_resolve, or must_resolve. Keep it null only when no existing resolution state actually applies. + +## Decision Sufficiency Rule + +An unresolved decision between options should not remain open merely because some uncertainty still exists. + +Keep a decision context unresolved only when you can identify a specific unresolved factor that could materially change which option is preferred. + +You may not resolve the decision context unless the user explicitly confirms (using their own words) that no other material uncertainty remains. If evidence appears sufficient but explicit confirmation is absent, preserve your directional conclusion in possibleInference and allow the system to ask the sufficiency confirmation / discovery question rather than closing the parent decision. + +## Decision Option Structure Rules +When the user presents mutually exclusive candidate actions for one unresolved choice: + +1. Create exactly one node of kind "unknown" to carry the decision question (the existing mechanism). Do not add a separate "decision" node kind. Keep that unknown as-is or create it fresh — do not duplicate it into every option. + +2. For each candidate path, create exactly one node of kind "option". The option's label names the alternative; its description states what that alternative entails. + +3. Link each option to the decision-context unknown using relationship "contained_in" (edge: option → unknown). Shared membership already implies these options are alternatives of each other — do not add an "alternative_to" edge between options. + +4. Attach consequences and evidence to the specific option they belong to via existing edge types ("causes", "may_cause", etc.). Each consequence's fromNodeId explicitly identifies its parent option. Do not collapse all alternatives into one generic trade-off description on a single node. + +5. A do-nothing / stay-put / current-state path is an option when it is genuinely one of the alternatives — represent it with kind "option" and label it clearly. Do not introduce an "is_baseline", "is_default", or "is_status_quo" field; baseline meaning is carried by label and consequences alone in this implementation. + +## Contract: structuralActionRequired Declaration Rule + +When answerMeaning.userSupportedMeaning is populated you MUST set structuralActionRequired to match what your proposal outputs: +- Set structuralActionRequired = true if and only if your proposal adds nodes, updates node status/value, or modifies edges (addedNodes.length > 0, updatedNodes with a meaningful change, or addedEdges.length > 0). +- Set structuralActionRequired = false if and only if your proposal has zero structural mutations — the two sentences are an intentional no-op declaration. + +## Additional Guidance +- If the answer only clarifies an existing unknown, prefer updatedNodes and resolvedUnknownNodeIds over creating duplicate nodes. +- When rule #6 applies to explicitly unresolved uncertainty: first check whether an existing unresolved node already represents the same uncertainty; if so, update/refine that existing structure rather than adding a duplicate; if no such node exists, add a new unknown that directly represents the unresolved uncertainty; do not use an edge alone to represent a previously unrepresented uncertainty. +- "Same uncertainty" means the same resolution question: resolving the existing unknown would also resolve the uncertainty introduced by the user's answer. Mere topical overlap (concerning the same topic, object, decision, or domain) is not automatically the same uncertainty. If the new concern can remain unresolved after the existing node is resolved, represent it separately as a distinct uncertainty. +- When an answer resolves an existing unknown, include that existing node ID in resolvedUnknownNodeIds and update that node rather than creating only a parallel observation. +- If the answer creates a more specific decision situation, add the smallest set of new nodes and edges needed to represent that situation and only its most consequential unknowns. +- If you add a new unknown, do not leave it floating: connect it with an added edge to the relevant decision/context node created or updated from the answer. +- If you add a new unknown, its description must do two jobs in one sentence: what is unknown, and why resolving it matters for the case. +- When selectedQuestion is provided, identify the specific material continuation factor as selectedQuestion.nodeId; the engine retains deterministic prerequisite ordering, validation, and formulation authority — it favours your selected node when it has no unresolved depends_on prerequisites from same-proposal additions, falls back to existing deterministic selection otherwise, and may choose a different question if structural constraints require. +- If rule #6 does not apply (the answer contains no user-supported meaning that requires graph progress) and there is no other justification for change, return empty arrays for every category. +- If rule #6 applies but you choose an update/refinement of existing structure, resolve an existing unknown, or add justified new structure, your structural proposal plus answerMeaning together represent the complete response — answerMeaning preserves semantic fidelity while structural mutation handles graph progress; neither replaces the other. +- If you add a new unknown with addedNodes, connect it with at least one addedEdge to an existing updated/resolved node or to a newly added non-unknown node from the answer. +- For answerMeaning.supportCategory, use only these exact values: ${["relative_priority_only", "conditional_tradeoff", "uncertain", "explicit_hard_constraint", "other"].join(" | ")}. Use other when none of the protected categories applies. +- For answerMeaning.resolutionGuidance, use only these exact values: ${["must_remain_unresolved", "may_resolve", "must_resolve"].join(" | ")}. Keep it null only when none of those existing resolution states genuinely applies. + +## Output Contract Reminder +Return one JSON object only, with exact field names and exact enum values. +Never include a full graph. +Never include any field other than the contract fields above. +`; +} diff --git a/tests/graph/episode-reasoning-input.test.js b/tests/graph/episode-reasoning-input.test.js new file mode 100644 index 0000000..b3dbbe7 --- /dev/null +++ b/tests/graph/episode-reasoning-input.test.js @@ -0,0 +1,377 @@ +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"); + }); +});