fix(confidence-engine): preserve investigation activity across turns

ThreadContributionsBadge, PriorContributionsSummary, and
SecondaryPreviousLearning all filtered contributions via
c.targetNodeId === nodeId. Multi-turn follow-up Contributions carry a
different immediate targetNodeId while the canonical origin remains on
Findings (originatingTargetNodeId).

Repaired: all contribution filters now match on EITHER
c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId.
handleDeconstructSubmit carries originatingTargetNodeId from
focusedPresentationItemId as provenance for cold-return recovery.
This commit is contained in:
2026-08-28 11:56:31 +01:00
parent b9a54589f0
commit 10cbcbdd05
3 changed files with 162 additions and 5 deletions
+11 -4
View File
@@ -471,7 +471,9 @@ function EvidenceLimitCard({ summary }) {
// ── Prior contribution summary (embedded within FocusedQuestionBody) ─── // ── Prior contribution summary (embedded within FocusedQuestionBody) ───
function PriorContributionsSummary({ nodeId, contributions }) { function PriorContributionsSummary({ nodeId, contributions }) {
const threadContribs = contributions.filter((c) => c.targetNodeId === nodeId); const threadContribs = (contributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
if (!threadContribs.length) return null; if (!threadContribs.length) return null;
// Exclude the most recent contribution — it is already shown as the current result above. // Exclude the most recent contribution — it is already shown as the current result above.
@@ -519,7 +521,9 @@ function PriorContributionsSummary({ nodeId, contributions }) {
// ── Standalone previous learning block (for two-column secondary placement) ─── // ── Standalone previous learning block (for two-column secondary placement) ───
function SecondaryPreviousLearning({ nodeId, contributions }) { function SecondaryPreviousLearning({ nodeId, contributions }) {
const threadContribs = contributions.filter((c) => c.targetNodeId === nodeId); const threadContribs = (contributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
if (!threadContribs.length) return null; if (!threadContribs.length) return null;
// Exclude the most recent contribution — it is already shown as the current result above. // Exclude the most recent contribution — it is already shown as the current result above.
@@ -567,7 +571,9 @@ function SecondaryPreviousLearning({ nodeId, contributions }) {
// ── Thread contributions badge (standalone — used outside focused body) ─── // ── Thread contributions badge (standalone — used outside focused body) ───
function ThreadContributionsBadge({ nodeId, contributions }) { function ThreadContributionsBadge({ nodeId, contributions }) {
const threadContribs = contributions.filter((c) => c.targetNodeId === nodeId); const threadContribs = (contributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
if (!threadContribs.length) return null; if (!threadContribs.length) return null;
// Show most recent contribution summary inline // Show most recent contribution summary inline
@@ -1401,7 +1407,7 @@ export default function ReasoningWorkspace({
if (!target) return; if (!target) return;
const priorContribs = (focusedContributions || []).filter( const priorContribs = (focusedContributions || []).filter(
(c) => c.targetNodeId === target, (c) => c.targetNodeId === target || c.originatingTargetNodeId === target,
); );
setFocusedPresentationItemId(target); setFocusedPresentationItemId(target);
@@ -1511,6 +1517,7 @@ export default function ReasoningWorkspace({
// Persist contribution to case-level owner (ScenarioForm) // Persist contribution to case-level owner (ScenarioForm)
onFocusedContribution?.({ onFocusedContribution?.({
targetNodeId, targetNodeId,
originatingTargetNodeId: focusedPresentationItemId,
targetLabel: targetNode?.label || "", targetLabel: targetNode?.label || "",
targetDescription: targetNode?.description || "", targetDescription: targetNode?.description || "",
question: focused.question, question: focused.question,
+18
View File
@@ -1017,6 +1017,24 @@ The remaining boundaries are NOT persistence issues. They belong to the next fea
- Finding merge/split ownership - Finding merge/split ownership
- Final Finding → graph mapping mechanism - Final Finding → graph mapping mechanism
### v0.49 — MULTI-TURN / COLD-RETURN ACTIVITY VISIBILITY REPAIR (2026-08-28)
**Closed:** Multi-turn/cold-return regression in Open Question activity visibility (INVESTIGATING cue).
**Root cause:** `ThreadContributionsBadge`, `PriorContributionsSummary`, and `SecondaryPreviousLearning` all filtered contributions via `c.targetNodeId === nodeId`. Multi-turn follow-up Contributions carry a different immediate `targetNodeId` while the canonical origin remains on Findings (`originatingTargetNodeId`). When only the follow-up turn survives cold return, the filter finds zero matches.
**Repaired identity rule:** All contribution filters now match on EITHER `c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId`. This captures both direct-target contributions and follow-up contributions whose origin anchors to a different Open Question.
**New persisted field on Contribution:** `originatingTargetNodeId` — set from `focusedPresentationItemId` at deconstruct submit time. It is NOT a new investigation flag; it is provenance that lets existing discovery surfaces recover multi-turn history.
**Files changed:**
- `components/reasoning-workspace.jsx` — patched 4 filter sites + `handleDeconstructSubmit` to carry `originatingTargetNodeId`
- `tests/open-questions-vs-assumptions.test.jsx` — added v0.49 multi-turn regression tests and repaired `getThreadContribs` helper
**No changes to:** Finding eligibility, Finding disposition, Current Understanding, Done-for-now promotion, SituationGraph, persistence provider, or graph reasoning.
---
### BRANCH CLEAN STATE ### BRANCH CLEAN STATE
This branch has NO tracked working tree changes at HEAD b215846. Documentation closure recorded in this section only. No production or test modifications required. This branch has NO tracked working tree changes at HEAD b215846. Documentation closure recorded in this section only. No production or test modifications required.
+133 -1
View File
@@ -749,7 +749,10 @@ describe("same-node focused result reopen", () => {
describe("Focused investigation history cue", () => { describe("Focused investigation history cue", () => {
function getThreadContribs(nodeId, contributions) { function getThreadContribs(nodeId, contributions) {
return (contributions || []).filter((c) => c.targetNodeId === nodeId); // v0.49 repaired: match on targetNodeId OR originatingTargetNodeId
return (contributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
} }
function showsInvestigatingCue(threadContribCount) { function showsInvestigatingCue(threadContribCount) {
@@ -856,4 +859,133 @@ describe("Focused investigation history cue", () => {
expect(threadContribs).toHaveLength(0); expect(threadContribs).toHaveLength(0);
expect(showsInvestigatingCue(threadContribs.length)).toBe(false); expect(showsInvestigatingCue(threadContribs.length)).toBe(false);
}); });
// ── v0.49 MULTI-TURN FOLLOW-UP REGRESSION ───────────────────
// Hypothesis: ThreadContributionsBadge depends only on the latest/direct
// Contribution targetNodeId, causing focused history to become invisible
// when follow-up turns retain a different immediate target identity.
it("multi-turn follow-up contribution with different targetNodeId causes OPEN QUESTION A to show NO INVESTIGATING (regression)", () => {
// Turn 1: direct contribution to Open Question A — this is the baseline that works
const contribTurn1 = {
id: "contrib-001",
targetNodeId: "oq-originating", // same as the Open Question node
question: "First focused question on A",
observations: ["Fact from turn 1"],
uncertainties: [],
assumptions: [],
relationships: [],
};
// Turn 2: follow-up contribution belongs to the SAME focused investigation
// but targets a different intermediate node (the follow-up itself)
const contribTurn2 = {
id: "contrib-002",
targetNodeId: "follow_up_intermediate", // DIFFERENT from oq-originating
question: "Follow-up on turn 1",
observations: ["Fact from turn 2"],
uncertainties: [],
assumptions: [],
relationships: [],
};
const contributions = [contribTurn1, contribTurn2];
// Both contributions exist — baseline check passes
expect(getThreadContribs("oq-originating", contributions).length).toBe(1);
expect(showsInvestigatingCue(getThreadContribs("oq-originating", contributions).length)).toBe(true);
});
it("multi-turn FOLLOW-UP ONLY: Open Question A shows INVESTIGATING when Turn 2 carries originatingTargetNodeId (repaired)", () => {
// After repair: follow-up contribution carries originatingTargetNodeId linking back to A.
const contribTurn2 = {
id: "contrib-002",
targetNodeId: "follow_up_intermediate",
originatingTargetNodeId: "oq-originating",
question: "Follow-up on turn 1",
observations: ["Fact from turn 2"],
uncertainties: [],
assumptions: [],
relationships: [],
};
const contributions = [contribTurn2];
// The repaired filter matches originatingTargetNodeId back to A
const threadContribs = getThreadContribs("oq-originating", contributions);
expect(threadContribs).toHaveLength(1);
expect(showsInvestigatingCue(threadContribs.length)).toBe(true);
});
it("multi-turn with contributing to A directly still shows INVESTIGATING (no regression for direct contributions)", () => {
const contribToOrigin = {
id: "contrib-005",
targetNodeId: "oq-originating",
observations: ["direct to origin"],
};
const threadContribs = getThreadContribs("oq-originating", [contribToOrigin]);
expect(threadContribs).toHaveLength(1);
expect(showsInvestigatingCue(threadContribs.length)).toBe(true);
});
it("multi-turn with originatingTargetNodeId recovers INVESTIGATING for A (repaired expectation)", () => {
// Simulated repaired state: Contribution carries originatingTargetNodeId
const contribTurn1 = {
id: "contrib-001",
targetNodeId: "oq-originating",
originatingTargetNodeId: "oq-originating",
observations: ["Fact from turn 1"],
};
const contribTurn2 = {
id: "contrib-002",
targetNodeId: "follow_up_intermediate",
originatingTargetNodeId: "oq-originating",
observations: ["Fact from turn 2"],
};
const contributions = [contribTurn1, contribTurn2];
// Repaired filter: matches direct OR originating target
const getThreadContribsRepaired = (nodeId, c) =>
(c || []).filter(
(item) => item.targetNodeId === nodeId || item.originatingTargetNodeId === nodeId,
);
const threadContribs = getThreadContribsRepaired("oq-originating", contributions);
expect(threadContribs).toHaveLength(2);
expect(showsInvestigatingCue(threadContribs.length)).toBe(true);
});
it("multi-turn: repaired filter recovers INVESTIGATING when ONLY follow-up exists (cold return repaired)", () => {
const contribTurn2 = {
id: "contrib-002",
targetNodeId: "follow_up_intermediate",
originatingTargetNodeId: "oq-originating",
observations: ["Fact from turn 2"],
};
const getThreadContribsRepaired = (nodeId, c) =>
(c || []).filter(
(item) => item.targetNodeId === nodeId || item.originatingTargetNodeId === nodeId,
);
const threadContribs = getThreadContribsRepaired("oq-originating", [contribTurn2]);
expect(threadContribs).toHaveLength(1);
expect(showsInvestigatingCue(threadContribs.length)).toBe(true);
});
it("unrelated follow-up does NOT falsely show INVESTIGATING on any Open Question", () => {
const unrelated = {
id: "contrib-unrelated",
targetNodeId: "some_other_node",
observations: [],
};
expect(getThreadContribs("oq-originating", [unrelated]).length).toBe(0);
expect(showsInvestigatingCue(getThreadContribs("oq-originating", [unrelated]).length)).toBe(false);
expect(getThreadContribs("some_other_node", [unrelated]).length).toBe(1);
// some_other_node would show INVESTIGATING, but it's NOT oq-originating
});
}); });