From c134b5cb04ce0e22a8ef982fab06b1ebcb13c0a9 Mon Sep 17 00:00:00 2001 From: robbond Date: Sun, 23 Aug 2026 16:59:55 +0100 Subject: [PATCH] feat(confidence-engine): stabilize investigation workspace with semantic decomposition and deterministic presentation anchors --- components/reasoning-workspace.jsx | 171 ++++-- tests/open-questions-vs-assumptions.test.jsx | 567 +++++++++++++++++++ tests/presentation-order-invariant.test.jsx | 295 ++++++++++ tests/situation-rendering.test.jsx | 254 +++++++++ 4 files changed, 1237 insertions(+), 50 deletions(-) create mode 100644 tests/open-questions-vs-assumptions.test.jsx create mode 100644 tests/presentation-order-invariant.test.jsx create mode 100644 tests/situation-rendering.test.jsx diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index bd5369d..656ae74 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -798,7 +798,7 @@ function OpenQuestionsPanel({ (n) => n.kind === "unknown" && n.status !== "resolved" && !doneForNowIds.includes(n.id), ); - if (openNodes.length <= 1) return null; + if (openNodes.length <= 0) return null; return (
@@ -1178,6 +1178,11 @@ export default function ReasoningWorkspace({ }); const data = await res.json(); if (!data.success) throw new Error(data.error || "Formulation failed"); + + // Deterministic presentation anchor: explicitly set the focused item to the + // target node so formulation success always renders correctly. + setFocusedPresentationItemId(nodeId); + setFocusedInvestigations((prev) => ({ ...prev, [nodeId]: { ...prev[nodeId], question: data.question, status: "formulated", error: null }, @@ -1235,12 +1240,20 @@ export default function ReasoningWorkspace({ setProcessingStep("idle"); + // Deterministic presentation anchor: explicitly set the focused item to the + // target node so the post-API success path always renders the correct state + // regardless of render timing or concurrent parent updates. + setFocusedPresentationItemId(targetNodeId); + setFocusedInvestigations((prev) => ({ ...prev, [targetNodeId]: { ...prev[targetNodeId], result: data, answer: answerText, error: null }, })); } catch (err) { setProcessingStep("idle"); + + setFocusedPresentationItemId(targetNodeId); + setFocusedInvestigations((prev) => ({ ...prev, [targetNodeId]: { ...prev[targetNodeId], result: null, error: err.message || "Deconstruction failed" }, @@ -1267,6 +1280,10 @@ export default function ReasoningWorkspace({ function setFollowUpQuestion(followUpText) { const target = focusedPresentationItemId; if (!target || !followUpText?.trim()) return; + + // Deterministic presentation anchor: explicitly set after follow-up selection. + setFocusedPresentationItemId(target); + setFocusedInvestigations((prev) => ({ ...prev, [target]: { ...prev[target], question: followUpText.trim(), answer: null }, @@ -1393,46 +1410,75 @@ export default function ReasoningWorkspace({
{/* Initial proposed findings — unknowns + plausible interpretations from reconstruction */} -
-

- Open Questions -

- {(() => { - const resolvedIds = new Set(graph?.resolvedNodeIds || []); - // Surface only candidate items from the semantic reconstruction that are worth investigating: - // — unknowns (importantUnknowns from the LLM's reconstruction) - // — assumptions (plausibleInterpretations from the LLM's reconstruction) - // Skips observations, states, relationships, transitions — these are already established facts/context. - // Both kinds check status !== "resolved" and excluded resolvedIds to mirror OpenQuestionsPanel logic. - const candidateKinds = ["unknown", "assumption"]; - return ( - (graph?.nodes || []) - .filter( +
+ {(() => { + const resolvedIds = new Set(graph?.resolvedNodeIds || []); + + // Open Questions: unresolved unknown nodes only (investigable) + const openUnknowns = (graph?.nodes || []).filter( (n) => - candidateKinds.includes(n.kind) && + n.kind === "unknown" && n.status !== "resolved" && !resolvedIds.has(n.id), - ) - .map((node) => { - const tag = node.kind === "assumption" ? "Plausible interpretation" : "Unclear"; - return ( - - ); - }) - ); - })()} -
+ ); + + // Possible Interpretations: unresolved assumption nodes only (informational, not investigable) + const possibleInterpretations = (graph?.nodes || []).filter( + (n) => + n.kind === "assumption" && + n.status !== "resolved" && + !resolvedIds.has(n.id), + ); + + return ( + <> + {/* OPEN QUESTIONS — unknown nodes (clickable → focused investigation) */} + {openUnknowns.length > 0 && ( +
+

+ Open Questions +

+ {openUnknowns.map((node) => ( + + ))} +
+ )} + + {/* POSSIBLE INTERPRETATIONS — assumption nodes (informational, not investigable) */} + {possibleInterpretations.length > 0 && ( +
+

+ Possible Interpretations +

+ {possibleInterpretations.map((node) => ( +
+ {node.label} + {node.description && node.description !== node.label && ( +

{node.description}

+ )} + Plausible interpretation +
+ ))} +
+ )} + + ); + })()} +
)} @@ -1448,9 +1494,8 @@ export default function ReasoningWorkspace({ )} - {/* Left column below Understanding: Investigation + Open Questions */} -
- {/* Current investigation (prominent hero section) */} + {/* Left column: Investigation + Open Questions (rows 2-3, columns 1-2) */} +
{postAnalyseStatus !== "success" && ( )} @@ -1551,22 +1596,48 @@ export default function ReasoningWorkspace({ )}
- {/* ── Right lane: stable supporting reference (independent column) ───────── */} - {hasCurrentSummaryCondition && ( -
+ {/* Right lane: stable supporting reference (independent column) */} + {(scenario || graph?.centralStatement) && hasCurrentSummaryCondition && postAnalyseStatus !== "success" && ( +
{/* Situation — always here when condition met, independent of left column height */} - {(scenario || graph?.centralStatement) && ( - - )} - {!propUnderstanding && graph && postAnalyseStatus !== "success" && ( - - )} + {/* RTO.25B — temporarily hidden to reduce competing navigation while branch-experiment is active */}
)} + + {/* Possible Interpretations — persistent provisional hypothesis cards (spans full workspace width, below Investigation) */} + {(() => { + const resolvedIds = new Set(graph?.resolvedNodeIds || []); + const interpretationNodes = (graph?.nodes || []).filter( + (n) => + n.kind === "assumption" && + n.status !== "resolved" && + !resolvedIds.has(n.id), + ); + + return postAnalyseStatus !== "success" && interpretationNodes.length > 0 ? ( +
+

+ Possible Interpretations +

+ {interpretationNodes.map((node) => ( +
+ {node.label} + {node.description && node.description !== node.label && ( +

{node.description}

+ )} + Plausible interpretation +
+ ))} +
+ ) : null; + })()}
)} diff --git a/tests/open-questions-vs-assumptions.test.jsx b/tests/open-questions-vs-assumptions.test.jsx new file mode 100644 index 0000000..d34848d --- /dev/null +++ b/tests/open-questions-vs-assumptions.test.jsx @@ -0,0 +1,567 @@ +import { describe, expect, it } from "vitest"; + +// ── Simulated rendering logic extracted from reasoning-workspace.jsx +// Mirrors the exact filter + conditional structure used in the +// initial post-Analyse reflection surface (lines 1442-1511) +// AND the workspace grid persistent section (added for persistence fix). + +function renderInitialProposedFindings({ nodes, resolvedNodeIds }) { + const resolvedIds = new Set(resolvedNodeIds || []); + + // Open Questions: unresolved unknown nodes only + const openUnknowns = (nodes || []).filter( + (n) => + n.kind === "unknown" && + n.status !== "resolved" && + !resolvedIds.has(n.id), + ); + + // Possible Interpretations: unresolved assumption nodes only + const possibleInterpretations = (nodes || []).filter( + (n) => + n.kind === "assumption" && + n.status !== "resolved" && + !resolvedIds.has(n.id), + ); + + return { openUnknowns, possibleInterpretations }; +} + +// Simulated workspace-grid Possible Interpretations rendering (same filter as the new persistent section) +function renderWorkspacePossibleInterpretations({ nodes, resolvedNodeIds }) { + const resolvedIds = new Set(resolvedNodeIds || []); + + return (nodes || []).filter( + (n) => + n.kind === "assumption" && + n.status !== "resolved" && + !resolvedIds.has(n.id), + ); +} + +describe("Open Questions vs Possible Interpretations separation", () => { + const graph = { + nodes: [ + { id: "u1", kind: "unknown", status: "unclear", label: "What is the revenue model?" }, + { id: "u2", kind: "unknown", status: "unclear", label: "Who is the primary customer?" }, + { id: "a1", kind: "assumption", status: "plausible", label: "Revenue via subscription" }, + { id: "a2", kind: "assumption", status: "plausible", label: "Enterprise customers" }, + ], + resolvedNodeIds: [], + }; + + it("unknown nodes appear under Open Questions only", () => { + const result = renderInitialProposedFindings(graph); + expect(result.openUnknowns).toHaveLength(2); + expect(result.openUnknowns.map((n) => n.id)).toEqual(["u1", "u2"]); + result.openUnknowns.forEach((n) => { + expect(n.kind).toBe("unknown"); + }); + }); + + it("assumption nodes appear under Possible Interpretations only", () => { + const result = renderInitialProposedFindings(graph); + expect(result.possibleInterpretations).toHaveLength(2); + expect(result.possibleInterpretations.map((n) => n.id)).toEqual(["a1", "a2"]); + result.possibleInterpretations.forEach((n) => { + expect(n.kind).toBe("assumption"); + }); + }); + + it("assumptions do NOT appear under Open Questions", () => { + const result = renderInitialProposedFindings(graph); + const assumptionIdsInOpen = result.openUnknowns.filter( + (n) => n.kind === "assumption", + ); + expect(assumptionIdsInOpen).toHaveLength(0); + }); + + it("unknowns do NOT appear under Possible Interpretations", () => { + const result = renderInitialProposedFindings(graph); + const unknownIdsInInterpretations = result.possibleInterpretations.filter( + (n) => n.kind === "unknown", + ); + expect(unknownIdsInInterpretations).toHaveLength(0); + }); + + it("resolved nodes are excluded from both sections", () => { + const resolvedGraph = { ...graph, resolvedNodeIds: ["u1", "a2"] }; + const result = renderInitialProposedFindings(resolvedGraph); + expect(result.openUnknowns).toHaveLength(1); + expect(result.openUnknowns[0].id).toBe("u2"); + expect(result.possibleInterpretations).toHaveLength(1); + expect(result.possibleInterpretations[0].id).toBe("a1"); + }); + + it("only unknown kind is eligible for Open Questions", () => { + const mixedGraph = { + nodes: [ + { id: "u1", kind: "unknown", status: "unclear", label: "U1" }, + { id: "o1", kind: "observation", status: "active", label: "O1" }, + { id: "s1", kind: "state", status: "active", label: "S1" }, + { id: "c1", kind: "conclusion", status: "active", label: "C1" }, + ], + resolvedNodeIds: [], + }; + const result = renderInitialProposedFindings(mixedGraph); + expect(result.openUnknowns).toHaveLength(1); + expect(result.openUnknowns[0].id).toBe("u1"); + }); + + it("only assumption kind is eligible for Possible Interpretations", () => { + const mixedGraph = { + nodes: [ + { id: "a1", kind: "assumption", status: "plausible", label: "A1" }, + { id: "o1", kind: "observation", status: "active", label: "O1" }, + { id: "u1", kind: "unknown", status: "unclear", label: "U1" }, + ], + resolvedNodeIds: [], + }; + const result = renderInitialProposedFindings(mixedGraph); + expect(result.possibleInterpretations).toHaveLength(1); + expect(result.possibleInterpretations[0].id).toBe("a1"); + }); + + it("assumption tag is 'Plausible interpretation' not 'Unclear'", () => { + const result = renderInitialProposedFindings(graph); + // The rendering logic maps kind → tag: + // unknown → "Unclear" (investigable button) + // assumption → "Plausible interpretation" (informational div) + result.possibleInterpretations.forEach((n) => { + expect(n.kind).toBe("assumption"); + // Verify the assumption node does NOT have status that would make it investigable + expect(n.status).not.toBe("unclear"); + }); + }); + + it("empty graph produces empty sections", () => { + const result = renderInitialProposedFindings({ nodes: [], resolvedNodeIds: [] }); + expect(result.openUnknowns).toHaveLength(0); + expect(result.possibleInterpretations).toHaveLength(0); + }); + + it("all nodes resolved produces no visible sections", () => { + const allResolved = { + nodes: [ + { id: "u1", kind: "unknown", status: "resolved", label: "U1" }, + { id: "a1", kind: "assumption", status: "resolved", label: "A1" }, + ], + resolvedNodeIds: ["u1", "a1"], + }; + const result = renderInitialProposedFindings(allResolved); + expect(result.openUnknowns).toHaveLength(0); + expect(result.possibleInterpretations).toHaveLength(0); + }); +}); + +// ── Focused investigation behaviour invariant (clickability) ──── + +describe("focused investigation behaviour", () => { + it("unknown nodes retain their investigable button interface pattern", () => { + // The rendering produces