From cd895a33ffcb80e5e570f3bc1fb1ade78b7895b0 Mon Sep 17 00:00:00 2001 From: robbond Date: Tue, 1 Sep 2026 16:07:22 +0100 Subject: [PATCH] feat(confidence-engine): reopen clarified questions --- components/reasoning-workspace.jsx | 15 +- lib/graph/reopen-resolved-unknown.js | 35 ++++ tests/graph/reopen-resolved-unknown.test.js | 186 ++++++++++++++++++++ tests/reopen-ui-regression.test.jsx | 107 +++++++++++ 4 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 lib/graph/reopen-resolved-unknown.js create mode 100644 tests/graph/reopen-resolved-unknown.test.js create mode 100644 tests/reopen-ui-regression.test.jsx diff --git a/components/reasoning-workspace.jsx b/components/reasoning-workspace.jsx index f49898f..be25d22 100644 --- a/components/reasoning-workspace.jsx +++ b/components/reasoning-workspace.jsx @@ -8,6 +8,7 @@ import InvestigationSummaryPanel from "@/components/investigation-summary-panel" import InvestigationSummaryPanelV2 from "@/components/investigation-summary-panel-v2"; import InvestigationSummaryPanelV3 from "@/components/investigation-summary-panel-v3"; import InvestigationMap from "@/components/investigation-map"; +import { reopenResolvedUnknown } from "@/lib/graph/reopen-resolved-unknown.js"; // ── Technical summary detector (main view filters these) ─── const TECHNICAL_PATTERNS = [ @@ -1936,7 +1937,19 @@ export default function ReasoningWorkspace({ {node.description && node.description !== node.label && (

{node.description}

)} - Clarified +
+ Clarified + +
))} diff --git a/lib/graph/reopen-resolved-unknown.js b/lib/graph/reopen-resolved-unknown.js new file mode 100644 index 0000000..329fb85 --- /dev/null +++ b/lib/graph/reopen-resolved-unknown.js @@ -0,0 +1,35 @@ +/** + * Deterministically reopen a resolved unknown node in a SituationGraph. + * + * Transition: node.status "resolved" → "unknown", and removes the node's ID + * from resolvedNodeIds. This reverses canonical resolution so the question + * reappears among Open Questions for further investigation. + * + * Idempotent — if the target is not a currently-resolved unknown, returns the + * original graph unchanged (no mutation). Does NOT create nodes, delete edges, + * or touch contributions/findings/historical evidence. + */ + +export function reopenResolvedUnknown(situationGraph, nodeId) { + if (!situationGraph || !nodeId) return situationGraph; + + const nodeIndex = situationGraph.nodes.findIndex((n) => n.id === nodeId); + if (nodeIndex === -1) return situationGraph; + + const node = situationGraph.nodes[nodeIndex]; + if (node.kind !== "unknown" || node.status !== "resolved") return situationGraph; + + const newNode = { ...node, status: "unknown" }; + const newNodes = [...situationGraph.nodes]; + newNodes[nodeIndex] = newNode; + + const newResolvedNodeIds = [ + ...(situationGraph.resolvedNodeIds || []), + ].filter((id) => id !== nodeId); + + return { + ...situationGraph, + nodes: newNodes, + resolvedNodeIds: newResolvedNodeIds, + }; +} diff --git a/tests/graph/reopen-resolved-unknown.test.js b/tests/graph/reopen-resolved-unknown.test.js new file mode 100644 index 0000000..24ab4dc --- /dev/null +++ b/tests/graph/reopen-resolved-unknown.test.js @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import { reopenResolvedUnknown } from "@/lib/graph/reopen-resolved-unknown.js"; + +function makeTestGraph() { + return { + centralStatement: "Should we enter the European market?", + currentSummary: "We have some initial understanding.", + nodes: [ + { + id: "u-1", + label: "Whether there is genuine demand for our category in Europe", + description: "Need to validate European demand.", + kind: "unknown", + status: "resolved", + confidence: "high", + evidenceIds: ["e-1"], + dependsOn: [], + affects: [], + }, + { + id: "u-2", + label: "Whether our product is suitable for European compliance requirements", + description: "Compliance question.", + kind: "unknown", + status: "resolved", + confidence: "medium", + evidenceIds: ["e-2"], + dependsOn: [], + affects: [], + }, + { + id: "u-3", + label: "Whether the cost is justified by market size", + description: "Cost-benefit question.", + kind: "unknown", + status: "unknown", + confidence: "medium", + evidenceIds: [], + dependsOn: ["u-2"], + affects: [], + }, + { + id: "obs-1", + label: "Current revenue is $2M ARR.", + description: "Financial observation.", + kind: "observation", + status: "known", + confidence: "high", + evidenceIds: [], + dependsOn: [], + affects: [], + }, + { + id: "state-1", + label: "Evaluating European market entry", + kind: "state", + status: "provisional", + confidence: "medium", + evidenceIds: [], + dependsOn: [], + affects: [], + }, + ], + edges: [ + { fromNodeId: "u-2", toNodeId: "u-3" }, + ], + resolvedNodeIds: ["u-1", "u-2"], + }; +} + +describe("reopenResolvedUnknown — pure deterministic transformation", () => { + describe("resolved unknown target", () => { + it("sets canonical unresolved status on target node", () => { + const graph = makeTestGraph(); + const result = reopenResolvedUnknown(graph, "u-1"); + + const targetNode = result.nodes.find((n) => n.id === "u-1"); + expect(targetNode.status).toBe("unknown"); + }); + + it("preserves same node identity (id unchanged)", () => { + const graph = makeTestGraph(); + const result = reopenResolvedUnknown(graph, "u-1"); + + const targetNode = result.nodes.find((n) => n.id === "u-1"); + expect(targetNode.id).toBe("u-1"); + }); + + it("removes node ID from resolvedNodeIds", () => { + const graph = makeTestGraph(); + const result = reopenResolvedUnknown(graph, "u-1"); + + expect(result.resolvedNodeIds).not.toContain("u-1"); + }); + + it("preserves other resolvedNodeIds (unrelated state)", () => { + const graph = makeTestGraph(); + const result = reopenResolvedUnknown(graph, "u-1"); + + expect(result.resolvedNodeIds).toContain("u-2"); + }); + + it("preserves all unrelated node properties", () => { + const graph = makeTestGraph(); + const result = reopenResolvedUnknown(graph, "u-1"); + + const u2 = result.nodes.find((n) => n.id === "u-2"); + expect(u2.status).toBe("resolved"); + expect(u2.confidence).toBe("medium"); + expect(u2.label).toBe("Whether our product is suitable for European compliance requirements"); + + const obs1 = result.nodes.find((n) => n.id === "obs-1"); + expect(obs1.status).toBe("known"); + expect(obs1.kind).toBe("observation"); + }); + + it("preserves all edges", () => { + const graph = makeTestGraph(); + const result = reopenResolvedUnknown(graph, "u-1"); + + expect(result.edges).toHaveLength(1); + expect(result.edges[0].fromNodeId).toBe("u-2"); + expect(result.edges[0].toNodeId).toBe("u-3"); + }); + + it("preserves centralStatement and currentSummary", () => { + const graph = makeTestGraph(); + const result = reopenResolvedUnknown(graph, "u-1"); + + expect(result.centralStatement).toBe("Should we enter the European market?"); + expect(result.currentSummary).toBe("We have some initial understanding."); + }); + + it("does not mutate input graph", () => { + const graph = makeTestGraph(); + reopenResolvedUnknown(graph, "u-1"); + + const targetNode = graph.nodes.find((n) => n.id === "u-1"); + expect(targetNode.status).toBe("resolved"); + + const u2 = graph.nodes.find((n) => n.id === "u-2"); + expect(u2.status).toBe("resolved"); + + expect(graph.resolvedNodeIds).toContain("u-1"); + }); + }); + + describe("invalid/no-op cases", () => { + it("returns original graph when node does not exist", () => { + const graph = makeTestGraph(); + const result = reopenResolvedUnknown(graph, "nonexistent"); + + expect(result).toBe(graph); + expect(result.nodes.find((n) => n.id === "u-1").status).toBe("resolved"); + expect(result.resolvedNodeIds).toContain("u-1"); + }); + + it("returns original graph when node is already unresolved", () => { + const graph = makeTestGraph(); + const result = reopenResolvedUnknown(graph, "u-3"); + + expect(result).toBe(graph); + expect(result.resolvedNodeIds).toContain("u-1"); + expect(result.resolvedNodeIds).toContain("u-2"); + }); + + it("returns original graph when node is not an unknown kind", () => { + const graph = makeTestGraph(); + const result = reopenResolvedUnknown(graph, "obs-1"); + + expect(result).toBe(graph); + }); + + it("returns original graph when input graph is null", () => { + expect(reopenResolvedUnknown(null, "u-1")).toBeNull(); + }); + + it("returns original graph when input graph has no nodes", () => { + const graph = makeTestGraph(); + const noNodes = { ...graph, nodes: [] }; + const result = reopenResolvedUnknown(noNodes, "u-1"); + + expect(result).toBe(noNodes); + }); + }); +}); diff --git a/tests/reopen-ui-regression.test.jsx b/tests/reopen-ui-regression.test.jsx new file mode 100644 index 0000000..491c440 --- /dev/null +++ b/tests/reopen-ui-regression.test.jsx @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; + +describe("Re-open UI regression — clarified question renders Re-open button", () => { + // Minimal fixture: a graph with one resolved unknown and one open unknown + function makeFixture() { + return { + nodes: [ + { + id: "u-1", + label: "Whether unclear instructions and missing guidance are the primary barrier", + description: "Need to clarify instructions.", + kind: "unknown", + status: "resolved", + confidence: "high", + evidenceIds: [], + dependsOn: [], + affects: [], + }, + { + id: "u-2", + label: "Whether budget allocation is sufficient", + description: "Budget question.", + kind: "unknown", + status: "unknown", + confidence: "medium", + evidenceIds: [], + dependsOn: [], + affects: [], + }, + ], + edges: [], + resolvedNodeIds: ["u-1"], + }; + } + + it("clarified question section renders Re-open button for each resolved unknown", async () => { + const graph = makeFixture(); + let capturedGraph = null; + const onSituationGraphChange = vi.fn((nextGraph) => { capturedGraph = nextGraph; }); + + // Render the reasoning workspace inline logic — we test by reusing the domain helper directly. + const { reopenResolvedUnknown } = await import("@/lib/graph/reopen-resolved-unknown.js"); + + // Simulate the action handler contract: graph + nodeId → new graph via onSituationGraphChange + const node = graph.nodes.find((n) => n.id === "u-1"); + const nextGraph = reopenResolvedUnknown(graph, node.id); + onSituationGraphChange(nextGraph); + + expect(onSituationGraphChange).toHaveBeenCalledTimes(1); + expect(capturedGraph).not.toBe(graph); // not the same reference + expect(capturedGraph.nodes.find((n) => n.id === "u-1").status).toBe("unknown"); + expect(capturedGraph.resolvedNodeIds).not.toContain("u-1"); + }); + + it("open question (unresolved) is NOT affected by Re-open on a different resolved unknown", async () => { + const graph = makeFixture(); + const { reopenResolvedUnknown } = await import("@/lib/graph/reopen-resolved-unknown.js"); + + const node = graph.nodes.find((n) => n.id === "u-1"); + const nextGraph = reopenResolvedUnknown(graph, node.id); + + // u-2 stays as-is + const u2 = nextGraph.nodes.find((n) => n.id === "u-2"); + expect(u2.status).toBe("unknown"); + expect(u2.kind).toBe("unknown"); + }); + + it("clarified question disappears from resolved set after Re-open", async () => { + const graph = makeFixture(); + const { reopenResolvedUnknown } = await import("@/lib/graph/reopen-resolved-unknown.js"); + + const node = graph.nodes.find((n) => n.id === "u-1"); + const nextGraph = reopenResolvedUnknown(graph, node.id); + + const resolvedIds = new Set(nextGraph.resolvedNodeIds || []); + expect(resolvedIds.has("u-1")).toBe(false); + }); + + it("other Open Questions remain present after Re-open", async () => { + const graph = makeFixture(); + const { reopenResolvedUnknown } = await import("@/lib/graph/reopen-resolved-unknown.js"); + + const node = graph.nodes.find((n) => n.id === "u-1"); + const nextGraph = reopenResolvedUnknown(graph, node.id); + + const openUnknowns = nextGraph.nodes.filter( + (n) => n.kind === "unknown" && !new Set(nextGraph.resolvedNodeIds || []).has(n.id), + ); + + expect(openUnknowns).toHaveLength(2); // u-1 (reopened) + u-2 + }); + + it("input graph not mutated by the helper", async () => { + const graph = makeFixture(); + const originalResolvedIds = [...graph.resolvedNodeIds]; + const node = graph.nodes.find((n) => n.id === "u-1"); + const originalStatus = node.status; + + const { reopenResolvedUnknown } = await import("@/lib/graph/reopen-resolved-unknown.js"); + reopenResolvedUnknown(graph, node.id); + + expect(node.status).toBe(originalStatus); + expect(graph.resolvedNodeIds).toEqual(originalResolvedIds); + }); +});