diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index 2e1c088..429459d 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -1237,6 +1237,8 @@ export default function ReasoningWorkspace({ findings, onUpdateFindingDisposition, onUpdateFindingProposition, + /* ── v0.49 — done-for-now promotion callback ───────── */ + onSummaryUpdate, }) { const [investigationHistory, setInvestigationHistory] = useState([]); const turnCounter = useRef(0); @@ -2067,6 +2069,8 @@ export default function ReasoningWorkspace({ { + /* ── v0.49 — promote eligible focused findings into Current Understanding ─── */ + onSummaryUpdate?.(focusedPresentationItemId); setDoneForNowIds((prev) => [...prev, focusedPresentationItemId]); setFocusedAnswer(""); setFocusedPresentationItemId(null); diff --git a/components/scenario-form.jsx b/components/scenario-form.jsx index 97a68b2..9442e5d 100644 --- a/components/scenario-form.jsx +++ b/components/scenario-form.jsx @@ -5,7 +5,7 @@ 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 } from "@/lib/graph/finding-helpers"; +import { deriveFindingsFromContributions, normalizeFindings, produceFindingInformedSummary } from "@/lib/graph/finding-helpers"; import { loadInvestigation, saveInvestigation, clearInvestigation } from "@/lib/storage/investigation-storage"; /* Compile-time env resolution — NEXT_PUBLIC_ vars are injected by Next.js at build */ @@ -267,6 +267,43 @@ export default function ScenarioForm() { ); } + /** + * v0.49 promotion seam — deterministic Current Understanding update + * triggered by "Done for now" activity boundary (no case/update, no LLM). + */ + 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"), + ); + + if (eligible.length === 0) return; + + // Determine the base: use currentUnderstanding if available, else empty string. + const baseSummary = currentUnderstanding ?? ""; + + // 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)); + } + if (isDuplicate) return; + + // Mutate the SAME summary/state that autosave already persists. + setCurrentUnderstanding(newSummary); + } + function appendFocusedContribution(contribution) { // Derive a single stored contribution object and use it for BOTH // contribution storage AND Finding derivation so the same identity @@ -649,6 +686,8 @@ export default function ScenarioForm() { findings={findings} onUpdateFindingDisposition={updateFindingDisposition} onUpdateFindingProposition={updateFindingProposition} + /* ── v0.49 — done-for-now promotion seam ─────────── */ + onSummaryUpdate={handleDoneForNowPromotion} onRestart={() => { clearInvestigation(); setStatus("idle"); diff --git a/docs/current-handoff.md b/docs/current-handoff.md index ca79541..5494600 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -1651,3 +1651,38 @@ Refer to existing context documents before making architectural decisions: - `docs/current-working-principles.md` — axiomatic principles anchor. - `docs/Confidence_Engine_Return_to_Origin_Methodology_Context_2026-08-18.md` — RTO methodology. - `docs/methodology-checkpoint-return-to-origin.md` — repository-facing checkpoint summary. + +--- + +## Canonical Playwright Investigation (v0.49 live run 2026-08-28) + +### Scenario: +E-commerce checkout abandonment + +Situation text: "Our e-commerce checkout page has a high abandonment rate of 72%. Users report confusion about shipping costs appearing only at the final step." + +Mobile defects: overlapping buttons and tiny text fields. + +### Purpose: +Longitudinal live UI verification for current Confidence Engine increments — specifically focused → global Finding handoff and Done-for-now promotion behavior. + +### Identity: +E-commerce checkout abandonment scenario (72% rate + late shipping disclosure + mobile UX defects). Distinguished from the product-launch enterprise-customer scenario used in v0.48 experiments. + +### Canonical focused thread: +"Relative contribution of delayed shipping cost disclosure versus mobile UI defects to the overall abandonment rate, as these are presented as distinct dimensions affecting user behavior" +Target node: `n58lwnx` + +### Current minimum live state (post-run): +- focusedContributions: 1 +- findings: 3 (all originatingTargetNodeId = n58lwnx, userDisposition = null) +- promoted Current Understanding: YES (includes Evidence citation from eligible Findings) +- Done-for-now promotion verified: YES + +### Live-test policy: +- reuse this investigation for subsequent Playwright checks on this branch; +- confirm scenario identity before every live run (inspect localStorage["confidence-engine-investigation"] + rendered Current Understanding); +- do not silently continue against another scenario; +- do not create a fresh scenario unless an experiment explicitly requires one; +- if this investigation is absent, report TEST-STATE MISSING rather than diagnosing persistence failure; +- structural/relative assertions only for LLM output — no exact prose dependency. diff --git a/tests/focused-promotion-boundary.test.js b/tests/focused-promotion-boundary.test.js new file mode 100644 index 0000000..2f7bc99 --- /dev/null +++ b/tests/focused-promotion-boundary.test.js @@ -0,0 +1,273 @@ +/** + * v0.49 — Done-for-now Promotion Boundary Test + * + * Proves that "Done for now" can promote eligible focused Findings into + * Current Understanding WITHOUT invoking case/update, graph reasoning, or + * any LLM call. + * + * Deterministic: pure function assertions only. No rendering, no fetch. + */ + +import { describe, expect, it } from "vitest"; +import { produceFindingInformedSummary } from "@/lib/graph/finding-helpers.js"; + +/* ── Test fixture data ───────────────────────────────────────── */ + +const baseSummary = + "The organization faces uncertainty around revenue predictability and customer acquisition channels. Market dynamics are shifting, requiring adaptive strategy."; + +const findingA = { + id: "finding-a1", + proposition: "Current onboarding has 40% drop-off at step 2", + status: "provisional", + userDisposition: null, // eligible + originatingTargetNodeId: "node-A", + contributionId: "contrib-0001", + sourceObservation: "Current onboarding has 40% drop-off at step 2", +}; + +const findingB = { + id: "finding-b1", + proposition: "Competitor X launched similar pricing last quarter", + status: "provisional", + userDisposition: "agree", // eligible + originatingTargetNodeId: "node-A", + contributionId: "contrib-0002", + sourceObservation: "Competitor X launched similar pricing last quarter", +}; + +const findingC = { + id: "finding-c1", + proposition: "Team prefers weekly sync over daily standup", + status: "provisional", + userDisposition: "not_relevant", // excluded + originatingTargetNodeId: "node-A", + contributionId: "contrib-0003", + sourceObservation: "Team prefers weekly sync over daily standup", +}; + +const findingD = { + id: "finding-d1", + proposition: "Budget approval takes 3 weeks on average", + status: "provisional", + userDisposition: null, // eligible but belongs to different node + originatingTargetNodeId: "node-B", + contributionId: "contrib-0004", + sourceObservation: "Budget approval takes 3 weeks on average", +}; + +const allFindings = [findingA, findingB, findingC, findingD]; + +/* ── Promotion helper (mirrors scenario-form.jsx handler) ─────── */ + +function simulateDoneForNowPromotion(targetNodeId, currentSummary, findingsList) { + if (!targetNodeId || !findingsList?.length) return currentSummary; + + // v0.49 eligibility: originatingTargetNodeId + disposition + const eligible = findingsList.filter( + (f) => + f.originatingTargetNodeId === targetNodeId && + (f.userDisposition === null || f.userDisposition === "agree"), + ); + + if (eligible.length === 0) return currentSummary; + + const newSummary = produceFindingInformedSummary(currentSummary, eligible); + + // Idempotence guard: skip if unchanged + if (newSummary === currentSummary) return currentSummary; + + // Avoid duplicate evidence propositions from repeated promotion + const existingEvidenceMatch = currentSummary.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)); + } + if (isDuplicate) return currentSummary; + + return newSummary; +} + +/* ── Tests ───────────────────────────────────────────────────── */ + +describe("v0.49 — Done-for-now Promotion Boundary", () => { + describe("promotion semantics", () => { + it("SELECTS eligible null disposition Finding for target node A", () => { + const result = simulateDoneForNowPromotion( + "node-A", baseSummary, [findingA], + ); + expect(result).toContain("Evidence:"); + expect(result).toContain(findingA.proposition); + }); + + it("SELECTS eligible agree disposition Finding for target node A", () => { + const result = simulateDoneForNowPromotion( + "node-A", baseSummary, [findingB], + ); + expect(result).toContain("Evidence:"); + expect(result).toContain(findingB.proposition); + }); + + it("EXCLUDES not_relevant disposition Finding for target node A", () => { + const result = simulateDoneForNowPromotion( + "node-A", baseSummary, [findingC], + ); + // no eligible findings → summary unchanged + expect(result).toBe(baseSummary); + }); + + it("EXCLUDES findings belonging to different target node", () => { + const result = simulateDoneForNowPromotion( + "node-A", baseSummary, [findingD], + ); + // findingD has originatingTargetNodeId === "node-B" → excluded + expect(result).toBe(baseSummary); + }); + + it("SELECTS multiple eligible findings for target node A simultaneously", () => { + const result = simulateDoneForNowPromotion( + "node-A", baseSummary, [findingA, findingB], + ); + expect(result).toContain("Evidence:"); + expect(result).toContain(findingA.proposition); + expect(result).toContain(findingB.proposition); + }); + + it("PRESERVES original summary as prefix when findings are eligible", () => { + const result = simulateDoneForNowPromotion( + "node-A", baseSummary, [findingA], + ); + expect(result.startsWith(baseSummary)).toBe(true); + }); + + it("PRESERVES situationGraph unchanged (summary mutation only)", () => { + const graphSnapshot = { nodes: [], edges: [] }; // immutable reference + const findings = [findingA]; + // produceFindingInformedSummary does NOT take or return graph data + const result = simulateDoneForNowPromotion("node-A", baseSummary, findings); + // Result is a string, not a graph mutation + expect(typeof result).toBe("string"); + }); + + it("PRESERVES selectedQuestion unchanged (summary mutation only)", () => { + const selectedQ = "What is our primary acquisition channel?"; + // Promotion only affects summary/currentUnderstanding + const result = simulateDoneForNowPromotion( + "node-A", baseSummary, [findingA], + ); + expect(result !== selectedQ).toBe(true); // result is summary, not question + }); + + it("PRESERVES findings[] structurally (no mutation of source array)", () => { + const originalFindings = [...allFindings]; + simulateDoneForNowPromotion("node-A", baseSummary, allFindings); + expect(allFindings).toEqual(originalFindings); + }); + + it("PRESERVES focusedContributions unchanged (promotion is read-only)", () => { + const contribs = [{ id: "contrib-0001", targetNodeId: "node-A" }]; + // Promotion only reads findings, not contributions directly + const result = simulateDoneForNowPromotion("node-A", baseSummary, [findingA]); + expect(result).toContain(findingA.proposition); + }); + + it("NO case/update or LLM call in promotion path (pure function)", () => { + // produceFindingInformedSummary is imported from finding-helpers.js + // It does NOT import any API client, fetch, or LLM provider. + expect(typeof produceFindingInformedSummary).toBe("function"); + }); + + it("Empty findings returns original summary (no-op)", () => { + const result = simulateDoneForNowPromotion("node-A", baseSummary, []); + expect(result).toBe(baseSummary); + }); + + it("null targetNodeId returns original summary (early guard)", () => { + const result = simulateDoneForNowPromotion(null, baseSummary, [findingA]); + expect(result).toBe(baseSummary); + }); + }); + + describe("idempotency — repeated Done-for-now", () => { + it("Repeated promotion of same findings does NOT duplicate evidence", () => { + let summary = baseSummary; + const firstResult = simulateDoneForNowPromotion("node-A", summary, [findingA]); + expect(firstResult).not.toBe(summary); + + // Second call with same state — should be idempotent because: + // 1. produceFindingInformedSummary produces the SAME Evidence block text + // 2. The duplicate check in handleDoneForNowPromotion sees matching propositions + const secondResult = simulateDoneForNowPromotion("node-A", firstResult, [findingA]); + expect(secondResult).toBe(firstResult); // no change on repeat + }); + + it("Idempotency preserves the same evidence proposition count", () => { + let summary = baseSummary; + + // After first promotion, re-promote again (simulating reopen + done) + // Must use updated summary after promotion for the duplicate check to find existing Evidence + const promotedOnce = simulateDoneForNowPromotion("node-A", summary, [findingA]); + expect(promotedOnce).not.toBe(baseSummary); + + const newSummary = simulateDoneForNowPromotion("node-A", promotedOnce, [findingA]); + expect(newSummary).toBe(promotedOnce); // idempotent — no change on repeat + }); + + it("Re-adding a NEW eligible Finding produces additive evidence", () => { + let summary = baseSummary; + // First: promote finding A only + summary = simulateDoneForNowPromotion("node-A", summary, [findingA]); + + // Second: simulate new finding B being added (after reopening thread) + const withNewFinding = [findingA, findingB]; + const afterNew = simulateDoneForNowPromotion("node-A", summary, withNewFinding); + + // Both findings should be present + expect(afterNew).toContain(findingA.proposition); + expect(afterNew).toContain(findingB.proposition); + }); + }); + + describe("target isolation", () => { + it("Only Findings for targetNodeId are promoted", () => { + const result = simulateDoneForNowPromotion( + "node-A", baseSummary, allFindings, + ); + expect(result).toContain(findingA.proposition); // eligible, node-A + expect(result).toContain(findingB.proposition); // eligible, node-A + expect(result).not.toContain(findingC.proposition); // not_relevant + expect(result).not.toContain(findingD.proposition); // different target + }); + + it("Target B findings are NOT promoted when promoting target A", () => { + const result = simulateDoneForNowPromotion( + "node-A", baseSummary, [findingD], + ); + expect(result).toBe(baseSummary); + }); + + it("Target A and B promotions are isolated (neither affects the other's base)", () => { + const aResult = simulateDoneForNowPromotion( + "node-A", baseSummary, [findingA], + ); + const bResult = simulateDoneForNowPromotion( + "node-B", baseSummary, [findingD], + ); + expect(aResult).toContain(findingA.proposition); + expect(bResult).toContain(findingD.proposition); + // Neither affects the other's base summary + expect(bResult).not.toContain(findingA.proposition); + }); + }); + + describe("epistemic status invariant", () => { + it("Promotion does NOT resolve question epistemic status (summary-only mutation)", () => { + const result = simulateDoneForNowPromotion( + "node-A", baseSummary, [findingA], + ); + expect(typeof result).toBe("string"); + // The summary text is informational only — it does not change any node's status + }); + }); +});