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
17 changes: 17 additions & 0 deletions docs/07-decisions/adr-087-process-step-model-reconciliation.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,18 @@ Concretely:
actions plus the Canvas Edit-mode UI. `onFactorControlAdd` (currently passed `undefined`)
gets wired.

> [!NOTE]
> **Stale as of IM-0b / IM-0b-2 (2026-05-30).** `onFactorControlAdd` is **already wired**
> (IM-0b — `CanvasWorkspace.handleFactorControlAdd` → `IP.goal.factorControls`); the
> "currently passed `undefined`" phrasing is historical. **IM-0b-2** moved `ctqColumn` /
> `tributaries` / `subgroupAxes` / hunch authoring into `canvasStore` actions dispatched
> by `ProcessMapBase` (the second persistence path is retired). **Scope cut:** per-step
> `capabilityScope` (`SpecRule[]`) authoring was **deferred** — the per-step specs editor
> keeps routing to project-wide `measureSpecs` via `setMeasureSpec`; canvasStore has **no**
> `setStepCapabilityScope` action. Deferred to the IM-5/IM-6 holistic design. The full
> visual retirement of `ProcessMapBase` is also deferred (it is now a thin dispatcher).
> See `investigations.md` "IM-0b-2 deferrals".

**Scope note (WHERE ≠ WHY).** A `stepId` answers _where in the process_ a measure, outcome,
factor, or condition sits — it is a location key, not a cause. Steps locate evidence; they do
not explain it. A suspected contribution (Hypothesis) is a mechanism nested **within** a
Expand Down Expand Up @@ -154,6 +166,11 @@ vocabulary only; it makes no causal claim.
- Rich-map authoring (`ctqColumn` / `capabilityScope` / `tributaries`) moves off the deprecated
`ProcessMapBase` into `canvasStore` actions + Canvas Edit-mode UI. `onFactorControlAdd`,
currently passed `undefined`, is wired.
<!-- STALE as of IM-0b / IM-0b-2 (2026-05-30): onFactorControlAdd is already wired (IM-0b).
IM-0b-2 moved ctqColumn/tributaries/subgroupAxes/hunch authoring into canvasStore
(ProcessMapBase dispatches; second persistence path retired). Per-step capabilityScope
authoring was DEFERRED to IM-5/IM-6 (specs still route to project-wide measureSpecs);
full visual retirement of ProcessMapBase also deferred. See investigations.md. -->
- The read-only join engine (`getStepColumnAssignments` at `frame/stepColumns.ts:40`,
`conditionReferencesStep` at `findings/hypothesisCondition.ts:160`) is unchanged — it already
resolves against rich-map nodes.
Expand Down
9 changes: 9 additions & 0 deletions docs/ephemeral/investigations.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ Code-level smells, UX follow-ups, and architectural questions surfaced during wo

**Promotion path:** the IM-4/IM-5 items graduate (mark `[RESOLVED]`) when those PRs land; the hooks test-tsc + IdeaGroupCard items are opportunistic. **Severity:** low–medium; none block IM-1; primary user flows intact.

### IM-0b-2 deferrals (canvasStore = rich-map authoring authority) [LOGGED 2026-05-30]

**Surfaced by:** IM-0b-2 — making `canvasStore` the single authoring authority for the rich-map fields (`ctqColumn` / tributaries / `subgroupAxes` / hunches). `ProcessMapBase`'s mutators now dispatch new canvasStore-backed props instead of building `next: ProcessMap` + `onChange`. Two scope cuts were deliberately deferred (user-approved minimal direction: migrate the persistence path, preserve behavior, defer the holistic capability question). Full build context: [[investigation-surface-build]] memory + `PLAN-im-0b-2.md`.

- **Net-new per-step `node.capabilityScope` (`SpecRule[]`) authoring — DEFERRED to the IM-5/IM-6 holistic design.** `ProcessMapBase` never wrote `node.capabilityScope`; its per-step specs editor routes to `setMeasureSpec(column, SpecLimits)` → project-wide `measureSpecs` (preserved exactly in IM-0b-2 — see the `// IM-0b-2 deferral:` comments at the `onStepSpecsChange → setMeasureSpec` seam in `ProcessMapBase.tsx` + `CanvasWorkspace.tsx`). Whether/how to author per-step capability scope is entangled with IM-5 (level-native contribution), IM-6 (Values⇄Capability view) and ADR-038/073 — it belongs in that design, not this structural migration. canvasStore therefore gained **no** `setStepCapabilityScope` action.
- **Full VISUAL retirement of `ProcessMapBase` — DEFERRED; verify the real need at IM-4 planning.** After IM-0b-2 `ProcessMapBase` is a thin dispatcher (all authoring flows through canvasStore), so it's harmless. Rebuilding ctqColumn/tributary/specs/hunch authoring as native Edit-mode zone UI in `ProcessStructureZone` (currently read-only) is speculative until IM-4 confirms the `Canvas/index.tsx` `canvas-authoring-map` panel actually needs removing. Don't rebuild UI speculatively now.

**Promotion path:** the `capabilityScope` authoring item graduates into the IM-5/IM-6 spec when that design lands; the `ProcessMapBase` visual-retirement item is verified (kept or actioned) at IM-4 planning. **Severity:** low; neither blocks IM-0b-2; the structural migration (single authoring authority, second persistence path retired) is complete + behavior-preserving.

### Investigation-model design direction (Clusters A + B + C) — unified canvas · drill-to-condition · level×lens · Measurement-Plan-as-DCP [PROMOTED 2026-05-29]

**Surfaced by:** Holistic design conversation 2026-05-29 (visual-companion brainstorm, opus). Settled the V1 investigation-model spine across **Cluster A** (PWA/Azure seam, #12 closure) + **Cluster C** (Findings/Hypotheses domain + canvas) + **Cluster B** (analysis surfaces — #11/#50/#51, resolved; see the Cluster B block below). Grounded against code (6 grounding workflows) + the methodology author's GB "Measure" decks (~144 pp).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ There is **no sync code** between #1 and #3 (grep returns zero); the IP's `goal.
- **Unify on one step-ID scheme** with a **no-data-migration IDB version bump** (wedge "no migration, no users" stance — no `migrateX()` helper). The orphaning risk is acceptable pre-launch; state it.
- Add the **`processLocation` (stepId) join key** (used by the DCP, §7) resolving against the canonical node id.
- Move rich-map authoring (`ctqColumn` / `capabilityScope` / tributaries) **off the deprecated `ProcessMapBase`** into `canvasStore` actions + Canvas Edit-mode UI; wire `onFactorControlAdd` (currently `undefined`).
<!-- STALE as of IM-0b / IM-0b-2 (2026-05-30): onFactorControlAdd was already wired in IM-0b. IM-0b-2 moved ctqColumn / tributaries / subgroupAxes / hunch authoring into canvasStore (ProcessMapBase dispatches; second persistence path retired). Per-step `capabilityScope` (SpecRule[]) authoring was DEFERRED to the IM-5/IM-6 holistic design — the per-step specs editor still routes to project-wide `measureSpecs` via `setMeasureSpec`. Full visual retirement of ProcessMapBase also deferred (now a thin dispatcher). See investigations.md "IM-0b-2 deferrals". -->
- Resolve the contradictory doc comments (`types.ts:52` vs `:166`) to state exactly what `stepId` resolves against.

> Cascade is concentrated on the small flat-model side (~9 files + ~6 `ProcessStepEntry` consumers + tests); the rich-map surface (~53 files) stays canonical and is not churned. `capabilityScope` is per-step — preserve per-step capability, introduce no roll-up across steps (ADR-073).
Expand Down
264 changes: 264 additions & 0 deletions packages/stores/src/__tests__/canvasStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,3 +653,267 @@ describe('canvasStore dispatch', () => {
expect(dispatchedNode?.order).toBe(directNode?.order);
});
});

// ────────────────────────────────────────────────────────────────────────────
// IM-0b-2 (ADR-087 §5): canvasStore becomes the SINGLE authoring authority for
// the rich-map fields previously mutated by `ProcessMapBase` → `onChange`.
// Each action mirrors the ProcessMapBase mutator it replaces, runs through
// `applyUndoable` (version bump + undo entry), and is method-only (NOT in the
// `CanvasAction` union — mirrors `addStepsFromColumn`). Ids/timestamps are
// minted deterministically by the store (no `Date.now()` / `Math.random()`).
// ────────────────────────────────────────────────────────────────────────────

describe('canvasStore setStepCtq (IM-0b-2 — per-step CTQ authoring)', () => {
it('sets the ctqColumn on a step as one undoable change', () => {
const stepId = addStepAndGetId('Fill');
const version = useCanvasStore.getState().canonicalMapVersion;

useCanvasStore.getState().setStepCtq(stepId, 'Fill_Weight');

const node = useCanvasStore.getState().canonicalMap.nodes.find(n => n.id === stepId);
expect(node?.ctqColumn).toBe('Fill_Weight');
expect(useCanvasStore.getState().canonicalMapVersion).not.toBe(version);
expect(useCanvasStore.getState().historyDepth()).toBe(2);
});

it('clears the ctqColumn when called with undefined', () => {
const stepId = addStepAndGetId('Fill');
useCanvasStore.getState().setStepCtq(stepId, 'Fill_Weight');

useCanvasStore.getState().setStepCtq(stepId, undefined);

const node = useCanvasStore.getState().canonicalMap.nodes.find(n => n.id === stepId);
expect(node?.ctqColumn).toBeUndefined();
});

it('is a no-op (no version bump, no history) for a missing step or unchanged value', () => {
const stepId = addStepAndGetId('Fill');
useCanvasStore.getState().setStepCtq(stepId, 'Fill_Weight');
const version = useCanvasStore.getState().canonicalMapVersion;
const depth = useCanvasStore.getState().historyDepth();

useCanvasStore.getState().setStepCtq('missing-step', 'X');
useCanvasStore.getState().setStepCtq(stepId, 'Fill_Weight');

expect(useCanvasStore.getState().canonicalMapVersion).toBe(version);
expect(useCanvasStore.getState().historyDepth()).toBe(depth);
});

it('round-trips through undo / redo', () => {
const stepId = addStepAndGetId('Fill');
useCanvasStore.getState().setStepCtq(stepId, 'Fill_Weight');

useCanvasStore.getState().undo();
expect(
useCanvasStore.getState().canonicalMap.nodes.find(n => n.id === stepId)?.ctqColumn
).toBeUndefined();

useCanvasStore.getState().redo();
expect(useCanvasStore.getState().canonicalMap.nodes.find(n => n.id === stepId)?.ctqColumn).toBe(
'Fill_Weight'
);
});
});

describe('canvasStore tributary authoring (IM-0b-2)', () => {
it('adds a tributary with a deterministic id and the supplied step + column', () => {
const stepId = addStepAndGetId('Fill');

useCanvasStore.getState().addTributary(stepId, 'Machine');

const tributaries = useCanvasStore.getState().canonicalMap.tributaries;
expect(tributaries).toHaveLength(1);
expect(tributaries[0]).toMatchObject({ stepId, column: 'Machine' });
expect(tributaries[0].id).toEqual(expect.any(String));
expect(tributaries[0].id).not.toBe('');
expect(useCanvasStore.getState().historyDepth()).toBe(2);
});

it('mints unique deterministic tributary ids (no Math.random / crypto)', () => {
const stepId = addStepAndGetId('Fill');

useCanvasStore.getState().addTributary(stepId, 'Machine');
useCanvasStore.getState().addTributary(stepId, 'Shift');

const ids = useCanvasStore.getState().canonicalMap.tributaries.map(t => t.id);
expect(new Set(ids).size).toBe(2);
});

it('removes a tributary and cascades subgroupAxes + hunch refs', () => {
const stepId = addStepAndGetId('Fill');
useCanvasStore.getState().addTributary(stepId, 'Machine');
const tributaryId = useCanvasStore.getState().canonicalMap.tributaries[0].id;
useCanvasStore.getState().toggleSubgroupAxis(tributaryId);
// A tributary-pinned hunch — must cascade away with the tributary.
useCanvasStore.getState().addHunch('Pinned hunch', { tributaryId });

// Pre-state: the cascade targets must exist before removal, else the
// post-removal not.toContain / !some assertions would pass vacuously.
const before = useCanvasStore.getState().canonicalMap;
expect(before.subgroupAxes ?? []).toContain(tributaryId);
expect((before.hunches ?? []).some(h => h.tributaryId === tributaryId)).toBe(true);

useCanvasStore.getState().removeTributary(tributaryId);

const map = useCanvasStore.getState().canonicalMap;
expect(map.tributaries.some(t => t.id === tributaryId)).toBe(false);
expect(map.subgroupAxes ?? []).not.toContain(tributaryId);
expect((map.hunches ?? []).some(h => h.tributaryId === tributaryId)).toBe(false);
});

it('removeTributary is a no-op for an unknown id', () => {
const stepId = addStepAndGetId('Fill');
useCanvasStore.getState().addTributary(stepId, 'Machine');
const version = useCanvasStore.getState().canonicalMapVersion;
const depth = useCanvasStore.getState().historyDepth();

useCanvasStore.getState().removeTributary('trib-missing');

expect(useCanvasStore.getState().canonicalMapVersion).toBe(version);
expect(useCanvasStore.getState().historyDepth()).toBe(depth);
});

it('round-trips addTributary through undo / redo', () => {
const stepId = addStepAndGetId('Fill');
useCanvasStore.getState().addTributary(stepId, 'Machine');

useCanvasStore.getState().undo();
expect(useCanvasStore.getState().canonicalMap.tributaries).toEqual([]);

useCanvasStore.getState().redo();
expect(useCanvasStore.getState().canonicalMap.tributaries).toHaveLength(1);
});
});

describe('canvasStore toggleSubgroupAxis (IM-0b-2)', () => {
it('adds the tributary id to subgroupAxes when toggled on', () => {
const stepId = addStepAndGetId('Fill');
useCanvasStore.getState().addTributary(stepId, 'Machine');
const tributaryId = useCanvasStore.getState().canonicalMap.tributaries[0].id;
const versionBefore = useCanvasStore.getState().canonicalMapVersion;
const depthBefore = useCanvasStore.getState().historyDepth();

useCanvasStore.getState().toggleSubgroupAxis(tributaryId);

expect(useCanvasStore.getState().canonicalMap.subgroupAxes).toEqual([tributaryId]);
// IM-0b-2: parity with sibling actions — a toggle bumps the map version and
// records exactly one undo entry (guards a direct mutation that skips applyUndoable).
expect(useCanvasStore.getState().canonicalMapVersion).not.toBe(versionBefore);
expect(useCanvasStore.getState().historyDepth()).toBe(depthBefore + 1);
});

it('removes the tributary id from subgroupAxes when toggled off', () => {
const stepId = addStepAndGetId('Fill');
useCanvasStore.getState().addTributary(stepId, 'Machine');
const tributaryId = useCanvasStore.getState().canonicalMap.tributaries[0].id;
useCanvasStore.getState().toggleSubgroupAxis(tributaryId);

useCanvasStore.getState().toggleSubgroupAxis(tributaryId);

expect(useCanvasStore.getState().canonicalMap.subgroupAxes).toEqual([]);
});

it('round-trips toggleSubgroupAxis through undo / redo', () => {
const stepId = addStepAndGetId('Fill');
useCanvasStore.getState().addTributary(stepId, 'Machine');
const tributaryId = useCanvasStore.getState().canonicalMap.tributaries[0].id;

useCanvasStore.getState().toggleSubgroupAxis(tributaryId);
useCanvasStore.getState().undo();
expect(useCanvasStore.getState().canonicalMap.subgroupAxes ?? []).toEqual([]);

useCanvasStore.getState().redo();
expect(useCanvasStore.getState().canonicalMap.subgroupAxes).toEqual([tributaryId]);
});
});

describe('canvasStore hunch authoring (IM-0b-2)', () => {
it('adds a hunch with deterministic id and trimmed text', () => {
useCanvasStore.getState().addHunch('Nozzle wear on night shift');

const hunches = useCanvasStore.getState().canonicalMap.hunches ?? [];
expect(hunches).toHaveLength(1);
expect(hunches[0].text).toBe('Nozzle wear on night shift');
expect(hunches[0].id).toEqual(expect.any(String));
expect(hunches[0].id).not.toBe('');
});

it('preserves the step / tributary pin passed by the UI', () => {
const stepId = addStepAndGetId('Fill');
useCanvasStore.getState().addTributary(stepId, 'Machine');
const tributaryId = useCanvasStore.getState().canonicalMap.tributaries[0].id;

useCanvasStore.getState().addHunch('Step hunch', { stepId });
useCanvasStore.getState().addHunch('Trib hunch', { tributaryId });

const hunches = useCanvasStore.getState().canonicalMap.hunches ?? [];
expect(hunches.find(h => h.text === 'Step hunch')?.stepId).toBe(stepId);
expect(hunches.find(h => h.text === 'Trib hunch')?.tributaryId).toBe(tributaryId);
});

it('does not add an empty hunch (no version bump, no history)', () => {
const version = useCanvasStore.getState().canonicalMapVersion;
useCanvasStore.getState().addHunch(' ');

expect(useCanvasStore.getState().canonicalMap.hunches ?? []).toEqual([]);
expect(useCanvasStore.getState().canonicalMapVersion).toBe(version);
expect(useCanvasStore.getState().historyDepth()).toBe(0);
});

it('mints unique deterministic hunch ids', () => {
useCanvasStore.getState().addHunch('First');
useCanvasStore.getState().addHunch('Second');

const ids = (useCanvasStore.getState().canonicalMap.hunches ?? []).map(h => h.id);
expect(new Set(ids).size).toBe(2);
});

it('removes a hunch by id', () => {
useCanvasStore.getState().addHunch('Nozzle wear');
const hunchId = (useCanvasStore.getState().canonicalMap.hunches ?? [])[0].id;

useCanvasStore.getState().removeHunch(hunchId);

expect(useCanvasStore.getState().canonicalMap.hunches ?? []).toEqual([]);
});

it('removeHunch is a no-op for an unknown id', () => {
useCanvasStore.getState().addHunch('Nozzle wear');
const version = useCanvasStore.getState().canonicalMapVersion;
const depth = useCanvasStore.getState().historyDepth();

useCanvasStore.getState().removeHunch('hunch-missing');

expect(useCanvasStore.getState().canonicalMapVersion).toBe(version);
expect(useCanvasStore.getState().historyDepth()).toBe(depth);
});

it('round-trips addHunch through undo / redo', () => {
useCanvasStore.getState().addHunch('Nozzle wear');

useCanvasStore.getState().undo();
expect(useCanvasStore.getState().canonicalMap.hunches ?? []).toEqual([]);

useCanvasStore.getState().redo();
expect(useCanvasStore.getState().canonicalMap.hunches ?? []).toHaveLength(1);
});

it('resets the deterministic id minters via getInitialState (no cross-test bleed)', () => {
const stepId = addStepAndGetId('Fill');
useCanvasStore.getState().addTributary(stepId, 'Machine');
useCanvasStore.getState().addHunch('First');
const firstTribId = useCanvasStore.getState().canonicalMap.tributaries[0].id;
const firstHunchId = (useCanvasStore.getState().canonicalMap.hunches ?? [])[0].id;

resetCanvasStore();

const stepId2 = addStepAndGetId('Fill');
useCanvasStore.getState().addTributary(stepId2, 'Machine');
useCanvasStore.getState().addHunch('First');
const secondTribId = useCanvasStore.getState().canonicalMap.tributaries[0].id;
const secondHunchId = (useCanvasStore.getState().canonicalMap.hunches ?? [])[0].id;

expect(secondTribId).toBe(firstTribId);
expect(secondHunchId).toBe(firstHunchId);
});
});
Loading