feat(confidence-engine): promote focused learning on done
This commit is contained in:
@@ -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({
|
||||
<FocusedWorkspaceNavigation
|
||||
nodeId={focusedPresentationItemId}
|
||||
doneForNow={() => {
|
||||
/* ── v0.49 — promote eligible focused findings into Current Understanding ─── */
|
||||
onSummaryUpdate?.(focusedPresentationItemId);
|
||||
setDoneForNowIds((prev) => [...prev, focusedPresentationItemId]);
|
||||
setFocusedAnswer("");
|
||||
setFocusedPresentationItemId(null);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user