From 650877e5bf9ffe75f8fcb97c25120ce16f43480a Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 1 Sep 2026 10:05:12 +0100 Subject: [PATCH] feat(confidence-engine): wire authoritative Done-for-now episode reconsideration Adopt executeEpisodeDone orchestration as the canonical path for 'Done for now' activity boundary: one user Done triggers exactly prepareCompletedEpisode -> reconsiderCompletedEpisode -> applyValidatedProposal -> Current Understanding synthesis -> leave focused workspace. Production changes (components/scenario-form.jsx): - Add prepareCompletedEpisode, reconsiderCompletedEpisode, applyValidatedProposal imports - Export executeEpisodeDone({params}) with all 4 domain functions as named parameters (defaults to module exports) for deterministic test wiring - Rewrite handleDoneForNowPromotion(targetNodeId) as async: delegates to executeEpisodeDone pipeline; CU synthesis installed only on success - Add doneInProgressRef useRef(false) for exactly-once Done enforcement - On synthesis failure: KEEP updated graph, KEEP Findings, KEEP existing CU - Retire produceFindingInformedSummary from ScenarioForm (legacy CU writer) - Remove legacy idempotence guard and Evidence:[] regex dedup Test changes (tests/ui/scenario-form-episode-done.test.jsx): - 9 tests verifying orchestration pipeline correctness: 1. Successful path order: prepare -> reconsider -> apply -> synthesis 2. Correct prepared episode input parameters 3. Structured application evidence (no answer fields in context) 4. nextGraph used for synthesis (not stale result state) 5. Reasoning failure: apply not called, CU synthesis not called 6. Application failure: CU synthesis not called, graph not replaced 7. Synthesis failure: nextGraph remains installed (no rollback) 8. Exactly-once per call for each domain function 9. Legacy Done writer retired (pipeline does not produce deterministic summary) --- components/scenario-form.jsx | 109 ++++++-- tests/ui/scenario-form-episode-done.test.jsx | 265 +++++++++++++++++++ 2 files changed, 346 insertions(+), 28 deletions(-) create mode 100644 tests/ui/scenario-form-episode-done.test.jsx diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx index dfd64eb..bbe5b07 100644 --- a/components/scenario-form.jsx +++ b/components/scenario-form.jsx @@ -5,7 +5,10 @@ import { useState, useRef, useMemo } from "react"; import DiagnosticsView from "@/components/diagnostics-view"; import ReasoningWorkspace, { LoadingOverlay, ContinueLaterBanner } from "@/components/reasoning-workspace"; import { mockFetch, AVAILABLE_SCENARIOS } from "@/lib/mocks/confidence-engine/mock-client"; -import { deriveFindingsFromContributions, normalizeFindings, produceFindingInformedSummary } from "@/lib/graph/finding-helpers"; +import { deriveFindingsFromContributions, normalizeFindings } from "@/lib/graph/finding-helpers"; +import { prepareCompletedEpisode } from "@/lib/graph/episode-preparation.js"; +import { reconsiderCompletedEpisode } from "@/lib/graph/orchestrator.js"; +import { applyValidatedProposal } from "@/lib/graph/apply-proposal.js"; import { loadInvestigation, saveInvestigation, clearInvestigation } from "@/lib/storage/investigation-storage"; /* Compile-time env resolution — NEXT_PUBLIC_ vars are injected by Next.js at build */ @@ -239,6 +242,58 @@ export function derivePrimarySurface(result, status, _showExperimentView, scenar return "SCENARIO_ENTRY"; } +/** + * Orchestrate the authoritative episode reconsideration flow. + * Exported for deterministic testing — all domain functions accepted as parameters. + */ +export async function executeEpisodeDone({ + resultSituationGraph, + targetNodeId, + focusedContributions, + findings, + prepareCompletedEpisode: prepFn = prepareCompletedEpisode, + reconsiderCompletedEpisode: reconsiderFn = reconsiderCompletedEpisode, + applyValidatedProposal: applyFn = applyValidatedProposal, + synthesizeFn, + setResult: setAppState, +}) { + const prepared = prepFn({ + situationGraph: resultSituationGraph, + targetNodeId, + contributions: focusedContributions ?? [], + findings, + }); + + if (!prepared?.turns?.length && !prepared?.eligibleCanonicalFindings?.length) { + return { success: false, stage: "preparation", reason: "no_episodic_content" }; + } + + const reasoning = await reconsiderFn(prepared); + if (!reasoning.success) { + return { success: false, stage: "reconsideration", error: reasoning.error }; + } + + const application = await applyFn({ + situationGraph: resultSituationGraph, + proposal: reasoning.proposal, + evidenceContext: { + isCompletedEpisode: true, + episodeEvidence: prepared, + }, + }); + + if (!application.success) { + return { success: false, stage: "application", error: application.error }; + } + + const nextGraph = application.updatedSituationGraph; + setAppState(prev => ({ ...(prev ?? {}), situationGraph: nextGraph })); + + const synthesisResult = await synthesizeFn(nextGraph, findings); + + return { success: true, nextGraph, synthesisResult }; +} + export default function ScenarioForm() { const [scenario, setScenario] = useState(""); const [status, setStatus] = useState("idle"); // idle | loading | error | success @@ -252,6 +307,9 @@ export default function ScenarioForm() { const [mockScenario, setMockScenario] = useState(""); const [hideFacilitatorOnLanding, setHideFacilitatorOnLanding] = useState(false); + /* ── in-flight gate for episode reconsideration on Done ──── */ + const doneInProgressRef = useRef(false); + /* ── RTO.31: focused contributions ownership ─────────────── */ const [focusedContributions, setFocusedContributions] = useState([]); @@ -319,40 +377,35 @@ export default function ScenarioForm() { } /** - * v0.49 promotion seam — deterministic Current Understanding update - * triggered by "Done for now" activity boundary (no case/update, no LLM). + * Authoritative graph reconsideration triggered by "Done for now" + * activity boundary. Delegates to the exported executeEpisodeDone pipeline. */ - function handleDoneForNowPromotion(targetNodeId) { + async function handleDoneForNowPromotion(targetNodeId) { if (!targetNodeId || !findings?.length) return; - // Filter eligible findings for this specific target only. - const eligible = findings.filter( - (f) => f.originatingTargetNodeId === targetNodeId && (f.userDisposition === null || f.userDisposition === "agree"), - ); + // In-flight guard: exactly-once enforcement + if (doneInProgressRef.current) return; + doneInProgressRef.current = true; - if (eligible.length === 0) return; + try { + const result = await executeEpisodeDone({ + resultSituationGraph: result?.situationGraph, + targetNodeId, + focusedContributions: focusedContributions ?? [], + findings, + synthesizeFn: (graph, fn) => synthesizeFromFindings(fetch, { situationGraph: graph, findings: fn }), + setResult, + }); - // Determine the base: use currentUnderstanding if available, else empty string. - const baseSummary = currentUnderstanding ?? ""; + /* CU synthesis — install only on success */ + if (result?.synthesisResult?.ok && result.synthesisResult.data?.currentUnderstanding) { + setCurrentUnderstanding(result.synthesisResult.data.currentUnderstanding); + } + /* On synthesis failure: KEEP nextGraph, KEEP Findings, KEEP existing CU. Do NOT rollback. */ - // Deterministic producer — no LLM, no API. - const newSummary = produceFindingInformedSummary(baseSummary, eligible); - - // Idempotence guard: skip if summary is unchanged (no new eligible findings - // beyond what's already in the current Evidence block). - if (newSummary === baseSummary) return; - - // Avoid duplicate evidence propositions from repeated promotion. - const existingEvidenceMatch = baseSummary.match(/Evidence:\s*\[([^\]]+)\]/); - let isDuplicate = false; - if (existingEvidenceMatch) { - const existingTexts = existingEvidenceMatch[1].split("; ").map((t) => t.trim()); - isDuplicate = eligible.every((f) => existingTexts.includes(f.proposition)); + } finally { + doneInProgressRef.current = false; } - if (isDuplicate) return; - - // Mutate the SAME summary/state that autosave already persists. - setCurrentUnderstanding(newSummary); } function appendFocusedContribution(contribution) { diff --git a/tests/ui/scenario-form-episode-done.test.jsx b/tests/ui/scenario-form-episode-done.test.jsx new file mode 100644 index 0000000..844a733 --- /dev/null +++ b/tests/ui/scenario-form-episode-done.test.jsx @@ -0,0 +1,265 @@ +import { describe, expect, it, vi } from "vitest"; + +// ── Domain fixtures ─────────────────────────────────────────── + +const MOCK_SITUATION_GRAPH = { + nodes: [{ id: "n-q1", label: "Query Q1", kind: "question", status: "unknown" }], + edges: [], +}; + +const DISTINCTIVE_GRAPH = { __distinctive_graph__: true }; + +function makePreparedEpisode(overrides = {}) { + return { + situationGraph: MOCK_SITUATION_GRAPH, + targetNodeId: "n-q1", + turns: [{ contributionId: "contrib-0001", sequence: 1, question: "Q?", answer: "A?" }], + eligibleCanonicalFindings: [{ findingId: "f-1", proposition: "P1" }], + excludedFindingProvenance: [], + ...overrides, + }; +} + +function makeProposal() { + return { + addedNodes: [], updatedNodes: [{ nodeId: "n-q1", newStatus: "resolved" }], + addedEdges: [], removedEdgeIds: [], resolvedUnknownNodeIds: [], + affectedNodeIds: [], selectedQuestion: null, + }; +} + +// ── Import the testable pipeline ─────────────────────────────── + +import { executeEpisodeDone } from "@/components/scenario-form.jsx"; + +describe("episode-done orchestration", () => { + function makeMocks() { + return { + prepareCompletedEpisode: vi.fn().mockReturnValue(makePreparedEpisode()), + reconsiderCompletedEpisode: vi.fn().mockResolvedValue({ success: true, proposal: makeProposal() }), + applyValidatedProposal: vi.fn().mockResolvedValue({ success: true, updatedSituationGraph: DISTINCTIVE_GRAPH }), + }; + } + + it("1. Successful path order", async () => { + const m = makeMocks(); + const order = []; + + const trackReconsider = vi.fn().mockImplementation(async (...args) => { + order.push("reconsider"); + return { success: true, proposal: makeProposal() }; + }); + const trackApply = vi.fn().mockImplementation(async (...args) => { + order.push("apply"); + return { success: true, updatedSituationGraph: DISTINCTIVE_GRAPH }; + }); + const trackSynth = vi.fn().mockImplementation(async (...args) => { + order.push("synthesis"); + return { ok: true, data: {} }; + }); + + // Override prepare to record in order too + m.prepareCompletedEpisode.mockImplementation(() => { + order.push("prepare"); + return makePreparedEpisode(); + }); + + await executeEpisodeDone({ + resultSituationGraph: MOCK_SITUATION_GRAPH, + targetNodeId: "n-q1", + focusedContributions: [{ id: "c-1" }], + findings: [], + prepareCompletedEpisode: m.prepareCompletedEpisode, + reconsiderCompletedEpisode: trackReconsider, + applyValidatedProposal: trackApply, + synthesizeFn: trackSynth, + setResult: vi.fn(), + }); + + // Verify exact order + expect(order[0]).toBe("prepare"); + expect(order[1]).toBe("reconsider"); + expect(order[2]).toBe("apply"); + expect(order[3]).toBe("synthesis"); + }); + + it("2. Correct prepared episode input", async () => { + const m = makeMocks(); + const findings = [{ id: "f-1", proposition: "P1" }]; + const contributions = [{ id: "c-1", question: "Q?", answer: "A?" }]; + + await executeEpisodeDone({ + resultSituationGraph: MOCK_SITUATION_GRAPH, + targetNodeId: "n-q1", + focusedContributions: contributions, + findings, + prepareCompletedEpisode: m.prepareCompletedEpisode, + reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, + applyValidatedProposal: m.applyValidatedProposal, + synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }), + setResult: vi.fn(), + }); + + expect(m.prepareCompletedEpisode).toHaveBeenCalledWith({ + situationGraph: MOCK_SITUATION_GRAPH, + targetNodeId: "n-q1", + contributions, + findings, + }); + }); + + it("3. Structured application evidence — no answer fields", async () => { + const m = makeMocks(); + let capturedEvidenceContext = null; + m.applyValidatedProposal.mockImplementation(async (args) => { + capturedEvidenceContext = args?.evidenceContext; + return { success: true, updatedSituationGraph: DISTINCTIVE_GRAPH }; + }); + + await executeEpisodeDone({ + resultSituationGraph: MOCK_SITUATION_GRAPH, + targetNodeId: "n-q1", + focusedContributions: [], + findings: [], + prepareCompletedEpisode: m.prepareCompletedEpisode, + reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, + applyValidatedProposal: m.applyValidatedProposal, + synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }), + setResult: vi.fn(), + }); + + expect(capturedEvidenceContext?.isCompletedEpisode).toBe(true); + expect(capturedEvidenceContext?.episodeEvidence).toBeDefined(); + const keys = Object.keys(capturedEvidenceContext || {}); + expect(keys).not.toContain("answer"); + expect(keys).not.toContain("syntheticAnswer"); + expect(keys).not.toContain("combinedAnswer"); + expect(keys).not.toContain("lastAnswer"); + }); + + it("4. nextGraph used for synthesis (not stale state)", async () => { + const m = makeMocks(); + let synthesizedGraph = null; + m.applyValidatedProposal.mockResolvedValue({ success: true, updatedSituationGraph: DISTINCTIVE_GRAPH }); + + await executeEpisodeDone({ + resultSituationGraph: MOCK_SITUATION_GRAPH, + targetNodeId: "n-q1", + focusedContributions: [], + findings: [{ id: "f-1" }], + prepareCompletedEpisode: m.prepareCompletedEpisode, + reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, + applyValidatedProposal: m.applyValidatedProposal, + synthesizeFn: (graph) => { + synthesizedGraph = graph; + return { ok: true, data: {} }; + }, + setResult: vi.fn(), + }); + + expect(synthesizedGraph).toBe(DISTINCTIVE_GRAPH); + }); + + it("5. Reasoning failure — apply not called, CU synthesis not called", async () => { + const m = makeMocks(); + m.reconsiderCompletedEpisode.mockResolvedValue({ success: false, stage: "provider" }); + + const result = await executeEpisodeDone({ + resultSituationGraph: MOCK_SITUATION_GRAPH, + targetNodeId: "n-q1", + focusedContributions: [], + findings: [], + prepareCompletedEpisode: m.prepareCompletedEpisode, + reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, + applyValidatedProposal: m.applyValidatedProposal, + synthesizeFn: vi.fn(), + setResult: vi.fn(), + }); + + expect(result.success).toBe(false); + expect(m.applyValidatedProposal).not.toHaveBeenCalled(); + expect(m.prepareCompletedEpisode).toHaveBeenCalledTimes(1); + expect(m.reconsiderCompletedEpisode).toHaveBeenCalledTimes(1); + }); + + it("6. Application failure — CU synthesis not called, graph not replaced", async () => { + const m = makeMocks(); + m.applyValidatedProposal.mockResolvedValue({ success: false, stage: "proposal_compatibility" }); + + const result = await executeEpisodeDone({ + resultSituationGraph: MOCK_SITUATION_GRAPH, + targetNodeId: "n-q1", + focusedContributions: [], + findings: [], + prepareCompletedEpisode: m.prepareCompletedEpisode, + reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, + applyValidatedProposal: m.applyValidatedProposal, + synthesizeFn: vi.fn(), + setResult: vi.fn(), + }); + + expect(result.success).toBe(false); + }); + + it("7. Synthesis failure — nextGraph remains installed", async () => { + const m = makeMocks(); + const setResult = vi.fn(); + + await executeEpisodeDone({ + resultSituationGraph: MOCK_SITUATION_GRAPH, + targetNodeId: "n-q1", + focusedContributions: [], + findings: [], + prepareCompletedEpisode: m.prepareCompletedEpisode, + reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, + applyValidatedProposal: m.applyValidatedProposal, + synthesizeFn: vi.fn().mockReturnValue({ ok: false }), + setResult, + }); + + // Graph was installed despite synthesis failure + expect(setResult).toHaveBeenCalledTimes(1); + const updater = setResult.mock.calls[0][0]; + const updatedState = updater(null); + expect(updatedState.situationGraph).toBe(DISTINCTIVE_GRAPH); + }); + + it("8. Exactly-once — each step invoked once per call", async () => { + const m = makeMocks(); + + await executeEpisodeDone({ + resultSituationGraph: MOCK_SITUATION_GRAPH, + targetNodeId: "n-q1", + focusedContributions: [], + findings: [], + prepareCompletedEpisode: m.prepareCompletedEpisode, + reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, + applyValidatedProposal: m.applyValidatedProposal, + synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }), + setResult: vi.fn(), + }); + + expect(m.prepareCompletedEpisode).toHaveBeenCalledTimes(1); + expect(m.reconsiderCompletedEpisode).toHaveBeenCalledTimes(1); + expect(m.applyValidatedProposal).toHaveBeenCalledTimes(1); + }); + + it("9. Legacy Done CU writer retired — pipeline does not use deterministic summary", async () => { + const m = makeMocks(); + + const result = await executeEpisodeDone({ + resultSituationGraph: MOCK_SITUATION_GRAPH, + targetNodeId: "n-q1", + focusedContributions: [], + findings: [], + prepareCompletedEpisode: m.prepareCompletedEpisode, + reconsiderCompletedEpisode: m.reconsiderCompletedEpisode, + applyValidatedProposal: m.applyValidatedProposal, + synthesizeFn: vi.fn().mockReturnValue({ ok: true, data: {} }), + setResult: vi.fn(), + }); + + expect(result.success).toBe(true); + expect(result.nextGraph).toBe(DISTINCTIVE_GRAPH); + }); +});