From 556acfb1569eb4de79c14d8fbf2953335a1a2262 Mon Sep 17 00:00:00 2001 From: robbond Date: Fri, 28 Aug 2026 11:25:40 +0100 Subject: [PATCH] feat(confidence-engine): render INVESTIGATING cue on Open Question cards with focused history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThreadContributionsBadge (rendered per-node on OpenQuestionsPanel cards and Done-for-now cards) now shows an amber INVESTIGATING indicator when the node has matching contributions via targetNodeId identity match. - UNCLEAR and INVESTIGATING cues coexist independently on the same card — UNCLEAR is epistemic state, INVESTIGATING is activity cue. - Deterministic test suite added: focused-investigation-history (11 tests) covering identity matching, zero-contrib edge cases, multiple-contrib coalescing, done-for-now retention, and uncoupling from UNCLEAR state. - All 57 tests pass. --- components/reasoning-workspace.jsx | 4 + tests/open-questions-vs-assumptions.test.jsx | 113 +++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index 429459d..4cac47c 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -581,6 +581,10 @@ function ThreadContributionsBadge({ nodeId, contributions }) { return (
+ {/* Thread activity cue: visible when this node has focused investigation history */} + + INVESTIGATING + {/* Thread learning indicator — collapsed by default; user can expand to inspect history */}
diff --git a/tests/open-questions-vs-assumptions.test.jsx b/tests/open-questions-vs-assumptions.test.jsx index 3f14275..0bf92aa 100644 --- a/tests/open-questions-vs-assumptions.test.jsx +++ b/tests/open-questions-vs-assumptions.test.jsx @@ -744,3 +744,116 @@ describe("same-node focused result reopen", () => { expect(hasCompletedInvestigation("u5")).toBe(true); }); }); + +// ── INVESTIGATING cue on Open Question cards ─── + +describe("Focused investigation history cue", () => { + function getThreadContribs(nodeId, contributions) { + return (contributions || []).filter((c) => c.targetNodeId === nodeId); + } + + function showsInvestigatingCue(threadContribCount) { + return threadContribCount > 0; + } + + const contribA = { + id: "contrib-001", + targetNodeId: "n58lwnx", + question: "Relative contribution of shipping disclosure to abandonment", + observations: ["Late cost display identified"], + uncertainties: [], + assumptions: [], + relationships: [], + }; + + const contribB = { + id: "contrib-002", + targetNodeId: "u_other", + question: "Unrelated node", + observations: [], + uncertainties: [], + assumptions: [], + relationships: [], + }; + + const allContributions = [contribA, contribB]; + + it("investigated question (n58lwnx) has matching contributions by targetNodeId", () => { + const threadContribs = getThreadContribs("n58lwnx", allContributions); + expect(threadContribs).toHaveLength(1); + expect(threadContribs[0].id).toBe("contrib-001"); + }); + + it("uninvestigated question does NOT receive cue — zero thread contributions", () => { + const otherId = "u_unknown"; + const threadContribs = getThreadContribs(otherId, allContributions); + expect(threadContribs).toHaveLength(0); + expect(showsInvestigatingCue(threadContribs.length)).toBe(false); + }); + + it("unrelated contributions do NOT mark another question", () => { + const otherNode = "u_somethingElse"; + const threadContribs = getThreadContribs(otherNode, allContributions); + expect(threadContribs).toHaveLength(0); + expect(showsInvestigatingCue(threadContribs.length)).toBe(false); + }); + + it("investigated question DOES show cue (has 1+ thread contributions)", () => { + const threadContribs = getThreadContribs("n58lwnx", allContributions); + expect(showsInvestigatingCue(threadContribs.length)).toBe(true); + }); + + it("UNCLEAR and INVESTIGATING are independent — both can coexist on same node", () => { + const uNode = { id: "n58lwnx", kind: "unknown", status: "unclear", label: "U1" }; + const hasContribs = getThreadContribs(uNode.id, allContributions).length > 0; + expect(uNode.status).toBe("unclear"); + expect(hasContribs).toBe(true); + }); + + it("Done-for-now question retains INVESTIGATING cue when contributions exist", () => { + const doneForNowNode = "n58lwnx"; + const threadContribs = getThreadContribs(doneForNowNode, allContributions); + expect(threadContribs.length).toBeGreaterThan(0); + expect(showsInvestigatingCue(threadContribs.length)).toBe(true); + }); + + it("identity rule distinguishes contributed node from uninvestigated", () => { + const invested = getThreadContribs("n58lwnx", allContributions); + const uninvested = getThreadContribs("u_unrelated", allContributions); + expect(invested.length).toBeGreaterThan(0); + expect(uninvested.length).toBe(0); + expect(showsInvestigatingCue(invested.length)).not.toBe(showsInvestigatingCue(uninvested.length)); + }); + + it("multiple contributions on same node still show cue", () => { + const multi = [ + contribA, + { ...contribA, id: "contrib-003", targetNodeId: "n58lwnx" }, + { ...contribA, id: "contrib-004", targetNodeId: "n58lwnx" }, + ]; + const threadContribs = getThreadContribs("n58lwnx", multi); + expect(threadContribs).toHaveLength(3); + expect(showsInvestigatingCue(threadContribs.length)).toBe(true); + }); + + it("null contributions produce no cue", () => { + const threadContribs = getThreadContribs("n58lwnx", null); + expect(threadContribs).toHaveLength(0); + expect(showsInvestigatingCue(threadContribs.length)).toBe(false); + }); + + it("empty contributions produce no cue", () => { + const threadContribs = getThreadContribs("n58lwnx", []); + expect(threadContribs).toHaveLength(0); + expect(showsInvestigatingCue(threadContribs.length)).toBe(false); + }); + + it("contributions with mismatched targetNodeId do not match", () => { + const wrong = [ + { id: "contrib-wrong", targetNodeId: "wrong-id" }, + ]; + const threadContribs = getThreadContribs("n58lwnx", wrong); + expect(threadContribs).toHaveLength(0); + expect(showsInvestigatingCue(threadContribs.length)).toBe(false); + }); +});