Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 48 additions & 15 deletions apps/azure/src/components/editor/AnalyzeWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
Minimap,
CANVAS_W,
CANVAS_H,
computeWallLayout,
buildWallLayoutArgs,
ActiveIPScopeRibbon,
useWallKeyboard,
useWallIsMobile,
Expand Down Expand Up @@ -510,21 +512,30 @@ export const AnalyzeWorkspace: React.FC<AnalyzeWorkspaceProps> = ({
[activeIPScope, scopedFindingIds, findingsState.findings]
);

// Phase 13 — pan-to-node: replicate WallCanvas's deterministic layout so the
// command palette can center the viewport on a hub by id. (IM-1: the
// question row is gone; only hub nodes are pan-targets.)
// Phase 13 — pan-to-node: center the viewport on a hub by id. IM-4c: consumes
// the SHARED computeWallLayout authority with the SAME inputs WallCanvas + the
// Minimap use (incl. tributary grouping), so the pan target always lands on the
// rendered card — no more linear-only duplicate that drifted under grouping.
const handleWallPanToNode = useCallback(
(nodeId: string) => {
const hubIndex = scopedHubs.findIndex(h => h.id === nodeId);
if (hubIndex >= 0) {
const hubSpacing = CANVAS_W / (scopedHubs.length + 1);
const layout = computeWallLayout(
buildWallLayoutArgs({
hubs: scopedHubs,
processMap,
groupByTributary: Boolean(processMap && wallGroupByTributary),
canvasW: CANVAS_W,
canvasH: CANVAS_H,
})
);
const pos = layout.hubPositions.get(nodeId);
if (pos) {
setWallPan(wallHubId, {
x: CANVAS_W / 2 - hubSpacing * (hubIndex + 1),
y: CANVAS_H / 2 - 400,
x: CANVAS_W / 2 - pos.x,
y: CANVAS_H / 2 - pos.y,
});
}
},
[scopedHubs, wallHubId, setWallPan]
[scopedHubs, processMap, wallGroupByTributary, wallHubId, setWallPan]
);

const handleReturnToImprovementProject = useCallback(() => {
Expand Down Expand Up @@ -557,12 +568,14 @@ export const AnalyzeWorkspace: React.FC<AnalyzeWorkspaceProps> = ({
// contribution view that renders it arrives in IM-5.
//
// NOTE: this reads `h.status` (the stored value) directly, not
// `deriveHypothesisStatus`. On main, `setHubStatus` has zero prod callers
// and factories.ts seeds 'proposed', so `h.status === 'confirmed'` is a dead
// branch here until IM-4b/IM-6 persists the derived value. The Wall surface
// (WallCanvas + MobileCardList) correctly calls `deriveHypothesisStatus`.
// See investigations.md §"stored-vs-derived status deferral (IM-4a)" for the
// open question: migrate these readers OR persist the derived value in IM-4b/IM-6.
// `deriveHypothesisStatus`. Status is derived (deriveHypothesisStatus) + the
// disconfirmation gesture — there is NO manual status-override action
// (IM-4c removed the dead setHubStatus orphan per spec §10 #1). factories.ts
// seeds 'proposed', so `h.status === 'confirmed'` is a dead branch here until
// IM-6 persists the derived value. The Wall surface (WallCanvas +
// MobileCardList) correctly calls `deriveHypothesisStatus`. See
// investigations.md §"stored-vs-derived status deferral (IM-4a)" for the open
// question: migrate these readers OR persist the derived value in IM-6.
const { hypotheses, ruledOut } = useMemo(() => {
const suspected: Hypothesis[] = [];
const ruled: Hypothesis[] = [];
Expand Down Expand Up @@ -679,6 +692,23 @@ export const AnalyzeWorkspace: React.FC<AnalyzeWorkspaceProps> = ({
[hypothesesState]
);

// IM-4c — "propose suspected mechanism from this finding". The Wall renders
// `hypothesesState.hubs` (the useHypotheses hook is the source of truth), so we
// create + connect through THAT hook — NOT analyzeStore.createHubFromFinding,
// which appends to a different collection that does NOT re-render this Wall.
// Mirrors handleCreateHub's create→connect path; name matches the store
// factory's "Suspected mechanism: {excerpt}" convention.
const handleProposeHypothesis = useCallback(
(findingId: string) => {
const finding = findingsState.findings.find(f => f.id === findingId);
const excerpt = (finding?.text ?? '').trim().slice(0, 80);
const name = excerpt.length > 0 ? `Suspected mechanism: ${excerpt}` : 'New mechanism branch';
const hub = hypothesesState.createHub(name, '');
hypothesesState.connectFinding(hub.id, findingId);
},
[findingsState.findings, hypothesesState]
);

const handleToggleHubSelect = useCallback(
(hubId: string) => {
const hub = hubs.find(h => h.id === hubId);
Expand Down Expand Up @@ -996,6 +1026,7 @@ export const AnalyzeWorkspace: React.FC<AnalyzeWorkspaceProps> = ({
pan={wallPan}
groupByTributary={Boolean(processMap && wallGroupByTributary)}
planningProps={enrichedPlanningProps}
onProposeHypothesis={handleProposeHypothesis}
/>
{/* Minimap + CommandPalette are desktop-only. WallCanvas
self-gates to MobileCardList below 768px, so these
Expand All @@ -1008,6 +1039,8 @@ export const AnalyzeWorkspace: React.FC<AnalyzeWorkspaceProps> = ({
zoom={wallZoom}
pan={wallPan}
onPanTo={(x, y) => setWallPan(wallHubId, { x, y })}
processMap={processMap}
groupByTributary={Boolean(processMap && wallGroupByTributary)}
/>
</div>
<CommandPalette
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,89 @@ describe('AnalyzeWorkspace Map/Wall toggle', () => {
});
});

// IM-4c — propose-hypothesis-from-finding app wiring (the createHubFromFinding
// TRAP). The Azure Wall renders `hypothesesState.hubs` (the useHypotheses
// hook), so the app MUST route propose through createHub + connectFinding on
// THAT hook — not analyzeStore.createHubFromFinding (a different collection
// that would NOT re-render the Wall). This asserts the wired path; the
// render-through is proven by WallCanvas.proposeHypothesis.seam.test.tsx.
describe('propose-hypothesis app wiring (createHubFromFinding trap)', () => {
beforeEach(() => {
capturedWallCanvasProps.current = null;
useCanvasViewportStore.getState().setViewMode('wall');
});

it('forwards onProposeHypothesis to WallCanvas', () => {
const props = makeMinimalProps();
props.hypothesesState.hubs = [
{
id: 'hub-1',
name: 'Existing',
synthesis: '',
findingIds: [],
status: 'proposed',
createdAt: '',
updatedAt: '',
},
] as never;
render(<AnalyzeWorkspace {...props} />);
expect(capturedWallCanvasProps.current?.onProposeHypothesis).toBeTypeOf('function');
});

it('firing it creates + connects through hypothesesState (the rendered-hubs path), not a bare store call', () => {
const createHub = vi.fn(() => ({ id: 'hub-new' }) as never);
const connectFinding = vi.fn();
const props = makeMinimalProps();
props.hypothesesState = {
...props.hypothesesState,
createHub,
connectFinding,
hubs: [
{
id: 'hub-1',
name: 'Existing',
synthesis: '',
findingIds: [],
status: 'proposed',
createdAt: '',
updatedAt: '',
},
],
} as never;
props.findingsState = {
...props.findingsState,
findings: [
{
id: 'f-orphan',
text: 'Coolant temp creeps',
evidenceType: 'data',
createdAt: 1,
deletedAt: null,
investigationId: 'inv',
context: { activeFilters: {}, cumulativeScope: null },
status: 'observed',
comments: [],
statusChangedAt: 1,
},
],
} as never;

render(<AnalyzeWorkspace {...props} />);

const onProposeHypothesis = capturedWallCanvasProps.current!.onProposeHypothesis as (
findingId: string
) => void;
onProposeHypothesis('f-orphan');

// Routes through the useHypotheses hook (the Wall's source of truth).
expect(createHub).toHaveBeenCalledTimes(1);
expect((createHub.mock.calls[0] as unknown[])[0]).toMatch(
/Suspected mechanism: Coolant temp/
);
expect(connectFinding).toHaveBeenCalledWith('hub-new', 'f-orphan');
});
});

describe('canAccess photo gate (2-tier ACL — photos are contributions)', () => {
const makeMember = (userId: string, role: 'lead' | 'member' | 'sponsor') => ({
id: `pm-${userId}`,
Expand Down
40 changes: 30 additions & 10 deletions apps/pwa/src/components/views/AnalyzeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
Minimap,
CANVAS_W,
CANVAS_H,
computeWallLayout,
buildWallLayoutArgs,
ActiveIPScopeRibbon,
useWallKeyboard,
useWallIsMobile,
Expand Down Expand Up @@ -157,22 +159,29 @@ const AnalyzeView: React.FC<AnalyzeViewProps> = ({
});

// Phase 13 — resolve a CommandPalette result id to a canvas-space pan target.
// Positioning mirrors WallCanvas's deterministic layout (hubs row at y=400,
// questions row at y=900). WallCanvas doesn't expose node positions, so this
// recomputation is a controlled duplication — refactor if the layout ever
// becomes dynamic.
// IM-4c: consumes the SHARED computeWallLayout authority with the SAME inputs
// WallCanvas + the Minimap use (incl. tributary grouping), so the pan target
// always lands on the rendered card — no recomputed duplicate.
const handlePanToNode = useCallback(
(nodeId: string) => {
const hubIndex = scopedWallHubs.findIndex(h => h.id === nodeId);
if (hubIndex >= 0) {
const hubSpacing = CANVAS_W / (scopedWallHubs.length + 1);
const layout = computeWallLayout(
buildWallLayoutArgs({
hubs: scopedWallHubs,
processMap,
groupByTributary: Boolean(processMap && wallGroupByTributary),
canvasW: CANVAS_W,
canvasH: CANVAS_H,
})
);
const pos = layout.hubPositions.get(nodeId);
if (pos) {
setWallPan(wallHubId, {
x: CANVAS_W / 2 - hubSpacing * (hubIndex + 1),
y: CANVAS_H / 2 - 400,
x: CANVAS_W / 2 - pos.x,
y: CANVAS_H / 2 - pos.y,
});
}
},
[scopedWallHubs, wallHubId, setWallPan]
[scopedWallHubs, processMap, wallGroupByTributary, wallHubId, setWallPan]
);

const handleReturnToImprovementProject = useCallback(() => {
Expand All @@ -182,6 +191,14 @@ const AnalyzeView: React.FC<AnalyzeViewProps> = ({
}
}, [returnNavigation]);

// IM-4c — "propose suspected mechanism from this finding". The PWA Wall reads
// hubs from useAnalyzeStore.hypotheses REACTIVELY (line above), so
// createHubFromFinding (which appends to that exact collection) re-renders the
// Wall with the new hypothesis card. No follow-through sync needed.
const handleProposeHypothesis = useCallback((findingId: string) => {
useAnalyzeStore.getState().createHubFromFinding(findingId);
}, []);

// Categorize hypothesis hubs for AnalyzeConclusion (IM-1: status-derived,
// replacing the retired Question causeRole split).
const { hypotheses, contributing, ruledOut } = useMemo(() => {
Expand Down Expand Up @@ -341,6 +358,7 @@ const AnalyzeView: React.FC<AnalyzeViewProps> = ({
pan={wallPan}
groupByTributary={Boolean(processMap && wallGroupByTributary)}
planningProps={planningProps}
onProposeHypothesis={handleProposeHypothesis}
/>
{/* Minimap + CommandPalette are desktop-only. WallCanvas
self-gates to MobileCardList below 768px. */}
Expand All @@ -352,6 +370,8 @@ const AnalyzeView: React.FC<AnalyzeViewProps> = ({
zoom={wallZoom}
pan={wallPan}
onPanTo={(x, y) => setWallPan(wallHubId, { x, y })}
processMap={processMap}
groupByTributary={Boolean(processMap && wallGroupByTributary)}
/>
</div>
<CommandPalette
Expand Down
4 changes: 2 additions & 2 deletions docs/05-technical/architecture-generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ tier: living
audience: agent
topic: [architecture, generated]
status: active
last-verified: 2026-05-30
last-verified: 2026-05-31
---

> AUTO-GENERATED by `pnpm docs:gen-arch` — do not edit by hand. Regenerate after changing `package.json#dependencies`, `tsconfig.json#paths`, `package.json#exports`, or `apps/*/src/index.css` `@source` directives.

# Architecture (auto-generated)

Generated: 2026-05-30. Source: `scripts/docs/gen-arch.mjs`.
Generated: 2026-05-31. Source: `scripts/docs/gen-arch.mjs`.

---

Expand Down
16 changes: 16 additions & 0 deletions docs/07-decisions/adr-086-unified-investigation-canvas.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ last-verified: 2026-05-29
**Status:** Accepted
**Date:** 2026-05-29

> **Amended 2026-05-31** (after the [2026-05-30 Wall-centric spec](../superpowers/specs/2026-05-30-investigation-wall-unified-canvas-design.md) + a 5-lens factor-model exploration): a cause's factors are a **derived projection, not a stored edge**; the typed factor↔hypothesis bipartite edge is **deferred**; the factor band's model-simplification UX (vital-few + full analyst control, R²adj + p) is a **V-next increment** over an engine that already exists. See the **Amendment** section after the Decision. The §Decision/§Consequences below record the original (heavier) reading; where they differ, the Amendment + the 2026-05-30 spec win.

## Context

The 2026-05-29 holistic investigation-surface brainstorm (Clusters A/B/C) settled the V1 investigation spine on a single graph: `y = f(x)` — an outcome decomposed into factors, with named explanations (Hypotheses) connected to the factors that support or refute them. Today that one graph is rendered by **two separate components** that the user experiences as two screens:
Expand Down Expand Up @@ -55,6 +57,20 @@ Clutter is solved by a **Focus lens, not a global force-graph.** Visible detail

**Disconfirmation recording is in scope.** A Hypothesis is not "confirmed" until it has ≥2 evidence types **and** a survived disconfirmation attempt. Recording a disconfirmation attempt is a first-class write, not a derived state.

## Amendment (2026-05-31) — factors are a derived projection; the model-builder is V-next

A 5-lens factor-model exploration (grounded against the shipped engine) + the [2026-05-30 Wall-centric spec](../superpowers/specs/2026-05-30-investigation-wall-unified-canvas-design.md) refine the bipartite reading above. These are binding where they differ from the original §Decision/§Consequences:

1. **A cause's factors are a DERIVED projection, NOT a stored edge.** "Its factors" (the Focus-lens possessive in §Decision) = `deriveBranchColumns(hub, findings)` (`packages/core/src/findings/mechanismBranch.ts:93`) ∪ the cause's findings' `activeFilters` columns ∪ any `CausalLink` naming it — intersected with the scope's ranked factor band. **Do NOT add a `Hypothesis.factorIds[]` (or `factorNames[]`) field.** A stored factor set would (a) freeze a ranking that _must_ recompute on every drill (`useScopedModels.filteredScope` drops the drilled-constant factor), (b) let a human assert factor-relevance over the deterministic engine (breaks engine-is-authority), and (c) re-couple WHERE and WHY (the one separation the model rests on). This dissolves the apparent §Decision tension between "factors feed the scope" (band) and "focus a cause → _its_ factors" (possessive): both hold once "its factors" is a _computed subset of the scope band_, not a stored ownership.

2. **The typed, persisted factor↔hypothesis support/refute edge is DEFERRED.** §Decision line 48 + §Consequences "re-lay-out factor x/y in `useEvidenceMapData` + restructure WallCanvas into one bipartite coordinate space" describe a heavier surface than V1 needs. `CausalLink` stays a factor→factor DAG edge (with its optional `hypothesisId`); a real typed factor→cause edge + a "promote factor onto a cause" gesture are built **only if the derivation proves insufficient in practice**. The Evidence Map remains the **separate cross-scope overview** (muuttuja kartta); it is not ported into the Wall.

3. **Factors render as the scope-level CONTRIBUTING-FACTORS band; the model-simplification UX is a V-next increment.** The parsimony engine **already exists** — `computeBestSubsets` (`packages/core/src/stats/bestSubsets.ts`) enumerates all 2^k−1 subsets sorted by adjusted R²; the overfit gap, obs/predictor ratio, VIF, and ordinal/disordinal interaction classification are all computed today but winner-only and unread by the UI. The V-next "vital-few model-builder" is **~90% UI over the existing engine**: a pre-selected simplest-adequate model the analyst can fully override (with a loud "↩ Use suggested model" snap-back — full control, never silent), surfacing only **adjusted R² + per-factor p** (no Mallows Cp/BIC on the surface — Cp may be an _internal_ picker metric only; "keep it simple and meaningful"). Toggling a factor off is a disconfirmation finding; a suspected-but-unmeasured factor routes to a Measurement Plan; a human override persists as a recorded **Finding** (an interpretation), never a stored factor field. The one genuinely net-new statistical primitive — a **selection-stability bootstrap** ("in 92% of resamples") — is deferred exactly one increment after the band ships.

4. **Focus lens never moves the model metrics.** Focusing a cause dims the band to that cause's _derived_ factors but does NOT recompute the R²adj/p header — there is no per-cause model. This keeps "in the model" (association/parsimony) from ever reading as "is the cause" (endorsement lives only in the evidence gate: ≥2 types + survived disconfirmation).

**Delivery:** IM-4a (spine wiring, PR #256) + IM-4b (collaboration + multi-scope + detached flows, PR #257) shipped. IM-4c ships the positioned scope band + Focus-lens dimming + orphan-finding lane + `createHubFromFinding` CTA (factors-as-band is correct for V1). The model-builder is a clean **V-next initiative** that upgrades the same band component in place. Deferred per spec §9: factor-family LOD + edge bundling, child-scope recursion, the ACH matrix (dropped). See [[investigation-surface-build]] and the decision-log entry of 2026-05-31.

## Rationale

- **One graph, one mental model.** `y = f(x)` is a single object. Surfacing it as two screens forces the user to be the join. A bipartite layout makes "which factors refute this explanation?" a glance, not a cross-screen reconciliation. The Focus lens is what makes a single canvas legible at scale — it is the load-bearing part of "unify them", not a nice-to-have.
Expand Down
Loading