fix(confidence-engine): scope focused presentation to active question

- FocusedQuestionBody derives thread-local contribution subset using
  targetNodeId || originatingTargetNodeId matching
- hasCompletedContext, latest completed contrib, and all effective
  presentation fallbacks use scoped collection only
- scenario-wide focusedContributions history preserved in memory
- Fresh Question B no longer bleeds Question A's content across
  every presentation surface (Previously answered, What this tells us,
  Still unclear, Questions this raises, Assumptions, Connections)
- Reopening or revisiting Question A still uses its own history
- Targeted regression: 3 new Vitest cases pass
- Handoff docs updated with v0.52 correction record
This commit is contained in:
2026-09-02 12:46:50 +01:00
parent a061428711
commit 2b2096e41d
3 changed files with 181 additions and 5 deletions
+8 -2
View File
@@ -143,6 +143,12 @@ function FocusedQuestionBody({
onUpdateFindingDisposition,
onUpdateFindingProposition,
}) {
// ── Active-thread contribution scoping (v0.52) ──
// focusedContributions is scenario-wide; present only the active node's contributions.
const threadContribs = (focusedContributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
const hasContent = focused?.question?.trim() || formulationStep === "active" || processingStep === "active" || focused?.error;
const hasResult = Boolean(focused?.result);
const hasAnswer = Boolean(focused?.answer);
@@ -151,7 +157,7 @@ function FocusedQuestionBody({
// Completed context: result (primary) OR prior contributions (fallback during processing/error).
// Processing and error are transient states — they must NOT collapse completed context.
const hasCompletedContext = Boolean(focused?.result) || (() => {
const pc = [...(focusedContributions || [])].reverse().find((c) => c?.question && c?.answer);
const pc = [...threadContribs].reverse().find((c) => c?.question && c?.answer);
return !!pc;
})();
@@ -159,7 +165,7 @@ function FocusedQuestionBody({
// After setFollowUpQuestion() mutates focused.question/answer, derive from the
// latest completed Contribution so the narrative remains correct.
// Active follow-up detection: primary via result (when result exists), fallback via priorContribs (error state may have null result).
const priorContribs = [...(focusedContributions || [])].reverse();
const priorContribs = [...threadContribs].reverse();
// Follow-ups from result are primary; priorContribs is fallback when result is null.
const followUpsFromResult = focused?.result?.possibleFollowUpQuestions || [];
const hasActiveFollowUpFromResult =
+41 -3
View File
@@ -276,8 +276,46 @@ A real completed investigation reached zero Open Questions, clarified questions
- This session corrected placement via Edit only; deterministic verification via targeted Vitest (145 tests) and build — deliberately did not repeat Playwright
- All 145 tests pass; production build compiles successfully
**Open defects (unchanged):**
- Focused-investigation state bleed: newly selected Open Question can show stale previous-question material — separate future increment
### Focused investigation presentation ownership — v0.52 correction (verified)
**Live evidence that motivated this correction:**
A fresh unanswered Question B displayed stale focused-investigation content from a previously answered Question A across every presentation surface:
- Previously answered / Your response
- What this tells us
- Still unclear
- Questions this raises
- Assumptions
- Connections
**Root cause:** `FocusedQuestionBody` in `components/reasoning-workspace.jsx` iterated over the scenario-wide `focusedContributions` array for all derivations (`hasCompletedContext`, latest completed contribution, and every effective-presentation fallback: observations, uncertainties, follow-ups, assumptions, relationships). No active-question scoping was applied.
**Correction applied — scoped contribution presentation:**
- `FocusedQuestionBody` now derives a thread-local subset before any derivation:
```js
const threadContribs = (focusedContributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
```
- `hasCompletedContext`, latest completed contribution, and all effective presentation fallbacks use that scoped collection;
- scenario-wide `focusedContributions` history is preserved in memory — only the presentation derivation is narrowed;
- `originatingTargetNodeId` is also checked so follow-up contributions remain attributed to their originating question;
- fresh Question B no longer inherits Question A's focused presentation content;
- reopening or revisiting Question A still correctly uses Question A's own historical contribution content.
**Verified:**
- targeted Vitest (`tests/open-questions-vs-assumptions.test.jsx`) — 3 new test cases (fresh B scoped to zero, active A retains its history, originatingTargetNodeId scoping) — pass
- `npm run build` — compiles successfully
- Rob manually verified the live UI on a persisted investigation: fresh unanswered Question B no longer shows stale focused-investigation content from Question B; confirmed across all six presentation surfaces listed above
**Verification notes:**
- Claude Playwright was not used for final verification because the canonical dev server was unavailable at that point
- Full Vitest suite was not re-run in this session (only targeted regression test)
### Open defects
- **Focused-investigation state bleed**: Resolved by v0.52 correction above (scoped presentation derivation). Verified by targeted Vitest, build, and Rob manual visual verification.
- Empty Done `no_episodic_content`: choosing Done without episodic content can produce `{ success: false, stage: "preparation", error: "no_episodic_content" }` — separate future increment
**Next restart point:** Place milestone at Open Questions position when reverting placement correction. See v0.51 placement section above for the exact ternary pattern.
**Next restart point:** The empty-Done `no_episodic_content` 400. Implement and verify that a Done action taken when no episodic evidence exists produces the same user-facing state (CU refresh with appropriate messaging) without a 400 error.
@@ -2876,3 +2876,135 @@ describe("Zero Open Questions — end-of-investigation review invitation", () =>
expect(state.clarificationNodes).toHaveLength(0);
});
});
// v0.52: Focused presentation ownership fresh question must not inherit another's content
describe("v0.52 focused presentation is scoped to active question ownership", () => {
// Simulates the exact scoped-contributions derivation in FocusedQuestionBody (v0.52 fix)
function deriveScopedContribs(focusedContributions, nodeId) {
return (focusedContributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
}
function simulateFQB(priorContribs, focused) {
const hasAnswer = Boolean(focused?.answer);
const hasResult = Boolean(focused?.result);
const hasCompletedContext = hasResult || (() => {
const pc = [...priorContribs].reverse().find((c) => c?.question && c?.answer);
return !!pc;
})();
const latestCompletedContrib = priorContribs.find((c) => c?.question && c?.answer);
const effectiveObservations = focused?.result?.observations ?? priorContribs.find((c) => c?.observations)?.observations;
const effectiveUncertainties = focused?.result?.uncertainties ?? priorContribs.find((c) => c?.uncertainties)?.uncertainties;
const effectiveFollowUps = focused?.result?.possibleFollowUpQuestions || priorContribs.find((c) => c?.possibleFollowUpQuestions)?.possibleFollowUpQuestions;
const effectiveAssumptions = focused?.result?.assumptions || priorContribs.find((c) => c?.assumptions)?.assumptions;
const effectiveRelationships = focused?.result?.relationships || priorContribs.find((c) => c?.relationships)?.relationships;
return {
hasCompletedContext,
latestCompletedContrib,
effectiveObservations,
effectiveUncertainties,
effectiveFollowUps,
effectiveAssumptions,
effectiveRelationships,
};
}
// Scenario: Question A has historical contributions; Question B is fresh
const questionAContrib = {
id: "contrib-a-1",
targetNodeId: "node-A",
originatingTargetNodeId: "node-A",
question: "What drives user engagement?",
answer: "Social proof and urgency signals.",
observations: ["Users respond to countdown timers"],
uncertainties: ["Unclear if this generalises beyond SaaS"],
possibleFollowUpQuestions: ["What is the optimal timer duration?"],
assumptions: ["Users are time-pressured"],
relationships: [["engagement", "temporal_pressure"]],
};
const scenarioWideContributions = [questionAContrib];
it("Case 1 — fresh B has no active-thread contributions (Question A content absent from Question B presentation)", () => {
const activeNodeId = "node-B"; // fresh question, no own contributions
const scopedContribs = deriveScopedContribs(scenarioWideContributions, activeNodeId);
expect(scopedContribs).toHaveLength(0);
const focused = {
status: "formulated",
question: "How many users visit daily?",
answer: null,
result: null,
};
const derived = simulateFQB(scopedContribs, focused);
// None of the A-owned fields should appear
expect(derived.hasCompletedContext).toBe(false);
expect(derived.latestCompletedContrib).toBeUndefined();
expect(derived.effectiveObservations).toBeUndefined();
expect(derived.effectiveUncertainties).toBeUndefined();
expect(derived.effectiveFollowUps).toBeUndefined();
expect(derived.effectiveAssumptions).toBeUndefined();
expect(derived.effectiveRelationships).toBeUndefined();
});
it("Case 2 — active node = A retains its own contribution history", () => {
const activeNodeId = "node-A"; // reopen Question A
const scopedContribs = deriveScopedContribs(scenarioWideContributions, activeNodeId);
expect(scopedContribs).toHaveLength(1);
expect(scopedContribs[0].id).toBe("contrib-a-1");
const focused = {
status: "formulated",
question: questionAContrib.question,
answer: questionAContrib.answer,
result: {
observations: questionAContrib.observations,
uncertainties: questionAContrib.uncertainties,
possibleFollowUpQuestions: questionAContrib.possibleFollowUpQuestions,
assumptions: questionAContrib.assumptions,
relationships: questionAContrib.relationships,
},
};
const derived = simulateFQB(scopedContribs, focused);
// A's own content is present
expect(derived.hasCompletedContext).toBe(true);
expect(derived.latestCompletedContrib.id).toBe("contrib-a-1");
expect(derived.effectiveObservations).toEqual(questionAContrib.observations);
expect(derived.effectiveUncertainties).toEqual(questionAContrib.uncertainties);
expect(derived.effectiveFollowUps).toEqual(questionAContrib.possibleFollowUpQuestions);
expect(derived.effectiveAssumptions).toEqual(questionAContrib.assumptions);
expect(derived.effectiveRelationships).toEqual(questionAContrib.relationships);
});
it("Case 3 — originatingTargetNodeId also scopes (follow-up contribution belongs to A)", () => {
const followUpContrib = {
id: "contrib-a-followup",
targetNodeId: "node-A-followup-intermediate",
originatingTargetNodeId: "node-A",
question: "What is the optimal timer duration?",
answer: "15-30 seconds is the sweet spot.",
observations: ["Timer above the CTA works best"],
};
const contribs = [followUpContrib];
// Matches A via originatingTargetNodeId
const scopedA = deriveScopedContribs(contribs, "node-A");
expect(scopedA).toHaveLength(1);
expect(scopedA[0].id).toBe("contrib-a-followup");
// Does NOT match B
const scopedB = deriveScopedContribs(contribs, "node-B");
expect(scopedB).toHaveLength(0);
});
});