From b8ba169d60ab9651c8b5bfb50edb60f0dac838aa Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Wed, 29 Apr 2026 19:14:52 +0300 Subject: [PATCH 01/20] docs: lock load-bearing decisions for multi-level SCOUT V1 --- ...26-04-29-multi-level-scout-v1-decisions.md | 119 ++++++++++++++++++ .../plans/2026-04-29-multi-level-scout-v1.md | 1 + 2 files changed, 120 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-29-multi-level-scout-v1-decisions.md diff --git a/docs/superpowers/plans/2026-04-29-multi-level-scout-v1-decisions.md b/docs/superpowers/plans/2026-04-29-multi-level-scout-v1-decisions.md new file mode 100644 index 000000000..1eb92484b --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-multi-level-scout-v1-decisions.md @@ -0,0 +1,119 @@ +--- +title: 'Multi-level SCOUT V1 — load-bearing decisions' +audience: [engineer, architect] +category: implementation +status: accepted +date: 2026-04-29 +related: + - multi-level-scout-design + - investigation-scope-and-drill-semantics + - adr-074-scout-level-spanning-surface-boundary-policy +--- + +# Multi-level SCOUT V1 — Load-Bearing Decisions + +This document locks three load-bearing architectural decisions flagged in the design spec's "Open in spec" section (§8 ambiguities #1, #4, #6). The other three ambiguities (#2, #3, #5) are deferred to their implementing tasks with specific lock points noted. + +--- + +## Decision #1: TimelineWindow attachment point + +**Decision:** `TimelineWindow` attaches as a top-level field on the **Investigation envelope**, not as an extension of `ProcessContext`. + +```typescript +// packages/core/src/types.ts (illustrative) +interface Investigation { + // existing fields + processContext: ProcessContext; + nodeMappings: Record; // from scope spec + + // NEW — V1 first slice + window: TimelineWindow; + + // …other fields (id, createdAt, etc.) +} +``` + +**Rationale:** `ProcessContext` is scope-binding-only — it holds the canonical map, factor roles, and spec rules. The timeline window is an analyst-time control that can change without invalidating the scope. Co-locating `window` with `nodeMappings` at the envelope level keeps scope and temporal framing coherent and makes it clear that the window can be adjusted independently of the process model. + +**Interaction:** This placement respects `nodeMappings` positioning (introduced by `docs/superpowers/specs/2026-04-29-investigation-scope-and-drill-semantics-design.md` §2) and avoids conflating data-binding with temporal filtering. + +--- + +## Decision #4: Append-mode row-merge keys + +**Decision:** Append-mode deduplicates rows by the tuple **(timestamp value, all currently-mapped factor columns, outcome column)**. + +**Algorithm:** + +- Timestamp is the discriminator for "newer reading" — when the same measurement recurs, the newest timestamp wins. +- The set of factor columns + outcome column identifies the specific measurement. Two rows with identical timestamp + factors + outcome are exact duplicates and are dropped. +- Rows where timestamp + factors match but outcomes differ → **corrections** (newer timestamp wins; older outcome is replaced). +- Logged as a merge report: count of new rows, duplicates dropped, and corrections applied. + +**Rationale:** This matches standard ETL practices for time-series re-upload (e.g., lab system reprocessing, sensor recalibration). The outcome column is included in the key because a re-upload may correct both measurement and outcome. Timestamp is always the tiebreaker to make the resolution deterministic and audit-friendly. + +**Implementation note:** The merge report is persisted alongside the `EvidenceSnapshot` and visible in the Investigation UI as a passive footer (no interactive corrections UI in V1). + +--- + +## Decision #6: Default window per mode + +**Decision:** Default windows are determined by `(mode × phase)` tuple: + +### Investigation Phase (all modes) + +- Default: **`openEnded`** from the investigation's `createdAt` to `today`. +- Rationale: An active investigation starts at a point in the past and continues into the present; `openEnded` reflects that continuity without requiring analysts to adjust the window as time passes. + +### Hub Phase, Capability Mode + +- Default: **`rolling`** matching the hub's `cadence` field: + - `weekly` → rolling 7 days + - `daily` → rolling 1 day + - `hourly` → rolling 24 hours + - Unset cadence → falls back to `rolling 30d` +- Rationale: Hub-time review is operational monitoring at the rhythm the hub defines. A rolling window keeps the most recent cadence cycle visible and aligns with Grafana / Power-BI defaults for live dashboards. + +### Hub Phase, All Other Modes (Performance, Standard, Yamazumi, Defect, Process Flow) + +- Default: **`cumulative`** (all data, no window applied). +- Rationale: These modes are not built for hub-time review in V1; the placeholder default preserves all evidence for post-hoc analysis. + +### Legacy B0 (no `nodeMappings`) + +- Default: **`cumulative`** always — B0 data has no recurring source and no temporal frame to anchor a rolling window. +- Rationale: B0 is historical/incident data; cumulative analysis is the only sensible default. + +**Implementation note:** Analysts can override any default window in the Timeline filter UI (`FilterContextBar`). These defaults apply only at investigation creation and hub entry. + +--- + +## Deferred Decisions + +### Ambiguity #2: `SpecLookupContext` shape + +- **Deferred to:** Task 7 (Router Implementation) +- **Lock point:** The router API will accept either the resolved `SpecRule` directly or a lookup tuple + resolver function. The shape is determined by how `dataRouter` is called from the strategy layer. +- **Reference:** `docs/superpowers/specs/2026-04-29-investigation-scope-and-drill-semantics-design.md` §4 defines lookup keys as `Record`. + +### Ambiguity #3: `ChartVariantId` taxonomy + +- **Deferred to:** Task 7 (Router Implementation) +- **Lock point:** Existing `ChartSlotType` union at `packages/core/src/analysisStrategy.ts:18-32` is the current source. `ChartVariantId` will equal `ChartSlotType` unless window-aware variants require a superset. +- **Reference:** Current variants: ichart, capability-ichart, cpk-scatter, yamazumi-chart, boxplot, distribution-boxplot, yamazumi-ichart, pareto, cpk-pareto, yamazumi-pareto, stats, histogram, yamazumi-summary, defect-summary. + +### Ambiguity #5: Drift threshold default + +- **Deferred to:** Task 5 (Drift Detector Implementation) +- **Lock point:** Set to **0.20 relative change** (20% Cpk delta) as the baseline methodology-informed default. CoScout coaching review will cover the rationale. +- **Rationale:** Matches Six Sigma "notable shift" heuristic; tuneable via investigation config. + +--- + +## Related Documents + +- Spec: `docs/superpowers/specs/2026-04-29-multi-level-scout-design.md` +- Scope spec: `docs/superpowers/specs/2026-04-29-investigation-scope-and-drill-semantics-design.md` +- Implementation plan: `docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md` +- Boundary policy (ADR-074): `docs/07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md` diff --git a/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md b/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md index 915f10318..712313073 100644 --- a/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md +++ b/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md @@ -6,6 +6,7 @@ status: draft date: 2026-04-29 related: - multi-level-scout-design + - multi-level-scout-v1-decisions - adr-074-scout-level-spanning-surface-boundary-policy - investigation-scope-and-drill-semantics --- From 00c32284adf2f82e219fc92bcbe3f924254e55fa Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Wed, 29 Apr 2026 19:19:24 +0300 Subject: [PATCH 02/20] feat(core): TimelineWindow discriminated union + type guards Adds TimelineWindow (fixed | rolling | openEnded | cumulative) type, TimelineWindowKind utility type, and four isXxxWindow type guards. Barrel exported from ./timeline and re-exported from core root. TDD: test written first, confirmed failing, then passing after impl. Co-Authored-By: ruflo --- packages/core/src/index.ts | 3 +++ .../core/src/timeline/__tests__/types.test.ts | 24 +++++++++++++++++ packages/core/src/timeline/index.ts | 2 ++ packages/core/src/timeline/types.ts | 26 +++++++++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 packages/core/src/timeline/__tests__/types.test.ts create mode 100644 packages/core/src/timeline/index.ts create mode 100644 packages/core/src/timeline/types.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b4fde1dae..7f926f474 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -888,3 +888,6 @@ export { // Project Metadata (Portfolio view) export { buildProjectMetadata } from './projectMetadata'; export type { ProjectMetadata } from './projectMetadata'; + +// Timeline window types (Multi-level SCOUT V1) +export * from './timeline'; diff --git a/packages/core/src/timeline/__tests__/types.test.ts b/packages/core/src/timeline/__tests__/types.test.ts new file mode 100644 index 000000000..ef0ccb8a8 --- /dev/null +++ b/packages/core/src/timeline/__tests__/types.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import type { TimelineWindow } from '../types'; +import { isFixedWindow, isRollingWindow, isOpenEndedWindow, isCumulativeWindow } from '../types'; + +describe('TimelineWindow type guards', () => { + it('discriminates each kind', () => { + const fixed: TimelineWindow = { + kind: 'fixed', + startISO: '2026-01-01T00:00:00Z', + endISO: '2026-01-31T23:59:59Z', + }; + const rolling: TimelineWindow = { kind: 'rolling', windowDays: 30 }; + const open: TimelineWindow = { kind: 'openEnded', startISO: '2026-04-01T00:00:00Z' }; + const cumulative: TimelineWindow = { kind: 'cumulative' }; + + expect(isFixedWindow(fixed)).toBe(true); + expect(isRollingWindow(rolling)).toBe(true); + expect(isOpenEndedWindow(open)).toBe(true); + expect(isCumulativeWindow(cumulative)).toBe(true); + + expect(isFixedWindow(rolling)).toBe(false); + expect(isRollingWindow(open)).toBe(false); + }); +}); diff --git a/packages/core/src/timeline/index.ts b/packages/core/src/timeline/index.ts new file mode 100644 index 000000000..a8282d4f0 --- /dev/null +++ b/packages/core/src/timeline/index.ts @@ -0,0 +1,2 @@ +export type { TimelineWindow, TimelineWindowKind } from './types'; +export { isFixedWindow, isRollingWindow, isOpenEndedWindow, isCumulativeWindow } from './types'; diff --git a/packages/core/src/timeline/types.ts b/packages/core/src/timeline/types.ts new file mode 100644 index 000000000..7d714c5e2 --- /dev/null +++ b/packages/core/src/timeline/types.ts @@ -0,0 +1,26 @@ +export type TimelineWindow = + | { kind: 'fixed'; startISO: string; endISO: string } + | { kind: 'rolling'; windowDays: number } + | { kind: 'openEnded'; startISO: string } + | { kind: 'cumulative' }; + +export type TimelineWindowKind = TimelineWindow['kind']; + +export function isFixedWindow(w: TimelineWindow): w is Extract { + return w.kind === 'fixed'; +} +export function isRollingWindow( + w: TimelineWindow +): w is Extract { + return w.kind === 'rolling'; +} +export function isOpenEndedWindow( + w: TimelineWindow +): w is Extract { + return w.kind === 'openEnded'; +} +export function isCumulativeWindow( + w: TimelineWindow +): w is Extract { + return w.kind === 'cumulative'; +} From 2d92565b2e48e678a7af681d3636af792a9d5bbe Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Wed, 29 Apr 2026 19:25:14 +0300 Subject: [PATCH 03/20] feat(core): applyWindow filters rows by TimelineWindow over a timeColumn Co-Authored-By: ruflo --- .../timeline/__tests__/applyWindow.test.ts | 54 ++++++++++++++++++ packages/core/src/timeline/applyWindow.ts | 56 +++++++++++++++++++ packages/core/src/timeline/index.ts | 1 + 3 files changed, 111 insertions(+) create mode 100644 packages/core/src/timeline/__tests__/applyWindow.test.ts create mode 100644 packages/core/src/timeline/applyWindow.ts diff --git a/packages/core/src/timeline/__tests__/applyWindow.test.ts b/packages/core/src/timeline/__tests__/applyWindow.test.ts new file mode 100644 index 000000000..b6f339480 --- /dev/null +++ b/packages/core/src/timeline/__tests__/applyWindow.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { applyWindow } from '../applyWindow'; +import type { DataRow } from '../../types'; + +const rows: DataRow[] = [ + { timestamp: '2026-03-01T08:00:00Z', value: 1 }, + { timestamp: '2026-03-15T12:00:00Z', value: 2 }, + { timestamp: '2026-04-01T08:00:00Z', value: 3 }, + { timestamp: '2026-04-29T08:00:00Z', value: 4 }, +]; + +describe('applyWindow', () => { + it('filters by fixed window', () => { + const result = applyWindow(rows, 'timestamp', { + kind: 'fixed', + startISO: '2026-03-10T00:00:00Z', + endISO: '2026-04-10T00:00:00Z', + }); + expect(result.map(r => r.value)).toEqual([2, 3]); + }); + + it('filters by rolling window from now', () => { + const now = new Date('2026-04-29T12:00:00Z'); + const result = applyWindow(rows, 'timestamp', { kind: 'rolling', windowDays: 7 }, now); + expect(result.map(r => r.value)).toEqual([4]); + }); + + it('filters by open-ended window (start to now)', () => { + const now = new Date('2026-04-29T12:00:00Z'); + const result = applyWindow( + rows, + 'timestamp', + { kind: 'openEnded', startISO: '2026-04-01T00:00:00Z' }, + now + ); + expect(result.map(r => r.value)).toEqual([3, 4]); + }); + + it('returns all rows for cumulative window', () => { + const result = applyWindow(rows, 'timestamp', { kind: 'cumulative' }); + expect(result.map(r => r.value)).toEqual([1, 2, 3, 4]); + }); + + it('skips rows where the timeColumn is null/missing', () => { + const withNulls: DataRow[] = [{ timestamp: null, value: 99 }, ...rows]; + const result = applyWindow(withNulls, 'timestamp', { kind: 'cumulative' }); + expect(result.map(r => r.value)).toEqual([1, 2, 3, 4]); // null-timestamp row dropped + }); + + it('returns empty array if timeColumn does not exist on rows', () => { + const result = applyWindow(rows, 'nonexistent', { kind: 'cumulative' }); + expect(result).toEqual([]); + }); +}); diff --git a/packages/core/src/timeline/applyWindow.ts b/packages/core/src/timeline/applyWindow.ts new file mode 100644 index 000000000..e68d30803 --- /dev/null +++ b/packages/core/src/timeline/applyWindow.ts @@ -0,0 +1,56 @@ +import type { DataRow } from '../types'; +import { parseTimeValue } from '../time'; +import type { TimelineWindow } from './types'; + +/** + * Filter rows by the active timeline window. + * + * Rules: + * - Rows with a null/unparseable timeColumn value are dropped (they have no temporal locus). + * - If the timeColumn does not exist on the row shape, returns an empty array + * (caller should detect-time-column at FRAME, not at apply time). + * - For 'rolling' and 'openEnded' windows, `now` defaults to current time; + * tests pass an explicit `now` for determinism. + */ +export function applyWindow( + rows: DataRow[], + timeColumn: string, + window: TimelineWindow, + now: Date = new Date() +): DataRow[] { + if (rows.length === 0) return []; + if (!(timeColumn in rows[0])) return []; + + if (window.kind === 'cumulative') { + return rows.filter(row => parseTimeValue(row[timeColumn]) !== null); + } + + let startMs: number; + let endMs: number; + + switch (window.kind) { + case 'fixed': + startMs = Date.parse(window.startISO); + endMs = Date.parse(window.endISO); + break; + case 'rolling': + endMs = now.getTime(); + startMs = endMs - window.windowDays * 24 * 60 * 60 * 1000; + break; + case 'openEnded': + startMs = Date.parse(window.startISO); + endMs = now.getTime(); + break; + default: { + const _exhaustive: never = window; + return _exhaustive; + } + } + + return rows.filter(row => { + const t = parseTimeValue(row[timeColumn]); + if (t === null) return false; + const ms = t.getTime(); + return ms >= startMs && ms <= endMs; + }); +} diff --git a/packages/core/src/timeline/index.ts b/packages/core/src/timeline/index.ts index a8282d4f0..e81d9546b 100644 --- a/packages/core/src/timeline/index.ts +++ b/packages/core/src/timeline/index.ts @@ -1,2 +1,3 @@ export type { TimelineWindow, TimelineWindowKind } from './types'; export { isFixedWindow, isRollingWindow, isOpenEndedWindow, isCumulativeWindow } from './types'; +export { applyWindow } from './applyWindow'; From c1baf82f523ef9b7e1c83be5d01118c2059c2601 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Wed, 29 Apr 2026 19:31:12 +0300 Subject: [PATCH 04/20] feat(core): detectScope classifies investigations as b0/b1/b2 by nodeMappings cardinality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements scope detection per §2 of the investigation-scope-and-drill-semantics spec. Accepts ProcessHubInvestigation (the actual type carrying nodeMappings via metadata), not a nonexistent Investigation envelope. 4 tests: absent/empty → b0, length===1 → b2, length>1 → b1. Co-Authored-By: ruflo --- .../core/src/__tests__/scopeDetection.test.ts | 36 +++++++++++++++++++ packages/core/src/index.ts | 3 ++ packages/core/src/scopeDetection.ts | 20 +++++++++++ 3 files changed, 59 insertions(+) create mode 100644 packages/core/src/__tests__/scopeDetection.test.ts create mode 100644 packages/core/src/scopeDetection.ts diff --git a/packages/core/src/__tests__/scopeDetection.test.ts b/packages/core/src/__tests__/scopeDetection.test.ts new file mode 100644 index 000000000..03994c236 --- /dev/null +++ b/packages/core/src/__tests__/scopeDetection.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { detectScope } from '../scopeDetection'; +import type { ProcessHubInvestigation, InvestigationNodeMapping } from '../processHub'; + +const makeInvestigation = (nodeMappings?: InvestigationNodeMapping[]): ProcessHubInvestigation => ({ + id: 'test', + name: 'Test Investigation', + modified: '2026-04-29T00:00:00.000Z', + metadata: { + processHubId: 'hub-1', + nodeMappings, + }, +}); + +describe('detectScope', () => { + it('returns b0 when nodeMappings is absent', () => { + expect(detectScope(makeInvestigation(undefined))).toBe('b0'); + }); + + it('returns b0 when nodeMappings is empty', () => { + expect(detectScope(makeInvestigation([]))).toBe('b0'); + }); + + it('returns b2 when nodeMappings has exactly one entry', () => { + const inv = makeInvestigation([{ nodeId: 'n1', measurementColumn: 'col1' }]); + expect(detectScope(inv)).toBe('b2'); + }); + + it('returns b1 when nodeMappings has more than one entry', () => { + const inv = makeInvestigation([ + { nodeId: 'n1', measurementColumn: 'col1' }, + { nodeId: 'n2', measurementColumn: 'col2' }, + ]); + expect(detectScope(inv)).toBe('b1'); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7f926f474..0229890c5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -891,3 +891,6 @@ export type { ProjectMetadata } from './projectMetadata'; // Timeline window types (Multi-level SCOUT V1) export * from './timeline'; + +// Investigation scope classification (Multi-level SCOUT V1) +export { detectScope, type Scope } from './scopeDetection'; diff --git a/packages/core/src/scopeDetection.ts b/packages/core/src/scopeDetection.ts new file mode 100644 index 000000000..4dc078ca5 --- /dev/null +++ b/packages/core/src/scopeDetection.ts @@ -0,0 +1,20 @@ +import type { ProcessHubInvestigation } from './processHub'; + +export type Scope = 'b0' | 'b1' | 'b2'; + +/** + * Classify an investigation by the cardinality of its nodeMappings. + * + * - b0: nodeMappings absent or empty (legacy, global-spec investigations) + * - b1: nodeMappings.length > 1 (multi-step investigation) + * - b2: nodeMappings.length === 1 (single-step deep dive) + * + * Per docs/superpowers/specs/2026-04-29-investigation-scope-and-drill-semantics-design.md §2. + * Mirrors the existing detectYamazumiFormat() / detectDefectFormat() pattern. + */ +export function detectScope(investigation: ProcessHubInvestigation): Scope { + const mappings = investigation.metadata?.nodeMappings ?? []; + if (mappings.length === 0) return 'b0'; + if (mappings.length === 1) return 'b2'; + return 'b1'; +} From f059c591d609854c9b59d13a8d9f680c7e6a3ba9 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Wed, 29 Apr 2026 20:08:59 +0300 Subject: [PATCH 05/20] docs: revise Decision #1 to reflect actual ProcessHubInvestigation type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original Decision #1 referenced a fictitious Investigation envelope with processContext.nodeMappings. The actual codebase models hub-attached investigations as ProcessHubInvestigation with metadata.nodeMappings. Updating to put TimelineWindow on ProcessHubInvestigationMetadata alongside nodeMappings — the intent (window separate from scope, co-located with mappings) is preserved. Caught during V1 Task 3 (detectScope) implementation when the implementer needed to operate against the actual type. Co-Authored-By: ruflo --- ...26-04-29-multi-level-scout-v1-decisions.md | 22 +++---- .../core/src/__tests__/appendMode.test.ts | 34 ++++++++++ packages/core/src/appendMode.ts | 62 +++++++++++++++++++ packages/core/src/index.ts | 3 + 4 files changed, 110 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/__tests__/appendMode.test.ts create mode 100644 packages/core/src/appendMode.ts diff --git a/docs/superpowers/plans/2026-04-29-multi-level-scout-v1-decisions.md b/docs/superpowers/plans/2026-04-29-multi-level-scout-v1-decisions.md index 1eb92484b..e4d94b98a 100644 --- a/docs/superpowers/plans/2026-04-29-multi-level-scout-v1-decisions.md +++ b/docs/superpowers/plans/2026-04-29-multi-level-scout-v1-decisions.md @@ -18,25 +18,25 @@ This document locks three load-bearing architectural decisions flagged in the de ## Decision #1: TimelineWindow attachment point -**Decision:** `TimelineWindow` attaches as a top-level field on the **Investigation envelope**, not as an extension of `ProcessContext`. +**Decision:** `TimelineWindow` attaches as a field on `ProcessHubInvestigationMetadata`, alongside `nodeMappings`. There is no abstract `Investigation` envelope in the codebase; `ProcessHubInvestigation.metadata` (defined in `packages/core/src/processHub.ts`) is the actual home for hub-attached scope + temporal framing. ```typescript -// packages/core/src/types.ts (illustrative) -interface Investigation { - // existing fields - processContext: ProcessContext; - nodeMappings: Record; // from scope spec +// packages/core/src/processHub.ts +export interface ProcessHubInvestigationMetadata { + processHubId?: string; + // …existing fields + nodeMappings?: InvestigationNodeMapping[]; // already exists, from scope spec // NEW — V1 first slice - window: TimelineWindow; - - // …other fields (id, createdAt, etc.) + window?: TimelineWindow; } ``` -**Rationale:** `ProcessContext` is scope-binding-only — it holds the canonical map, factor roles, and spec rules. The timeline window is an analyst-time control that can change without invalidating the scope. Co-locating `window` with `nodeMappings` at the envelope level keeps scope and temporal framing coherent and makes it clear that the window can be adjusted independently of the process model. +**Rationale:** The codebase models hub-attached investigations as `ProcessHubInvestigation` (a thin wrapper around `id`, `name`, `modified`, `metadata`). `metadata` already holds `nodeMappings`, `processHubId`, and other scope-binding fields. Co-locating `window` here keeps scope and temporal framing coherent and avoids inventing a new envelope. The window is optional (V1 backward-compat); when absent, defaults are applied per Decision #6. + +**Interaction:** This placement respects `nodeMappings` positioning in `metadata` and aligns with how `processHubId` is already plumbed through Azure's `useHubProvision` and related hooks. -**Interaction:** This placement respects `nodeMappings` positioning (introduced by `docs/superpowers/specs/2026-04-29-investigation-scope-and-drill-semantics-design.md` §2) and avoids conflating data-binding with temporal filtering. +**Adaptation note (2026-04-29):** Originally Decision #1 said "Investigation envelope, not ProcessContext" — but `ProcessContext` does not exist as a TypeScript type in the codebase, and there is no `Investigation` envelope distinct from `ProcessHubInvestigation`. Updated during V1 Task 3 implementation when `detectScope` needed to operate against the actual type. The intent (window separate from scope, but co-located with mappings) is preserved. --- diff --git a/packages/core/src/__tests__/appendMode.test.ts b/packages/core/src/__tests__/appendMode.test.ts new file mode 100644 index 000000000..b823f174f --- /dev/null +++ b/packages/core/src/__tests__/appendMode.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { mergeRows } from '../appendMode'; +import type { DataRow } from '../types'; + +describe('mergeRows', () => { + const keyColumns = ['timestamp', 'shift', 'value']; + + it('appends rows with no overlap', () => { + const existing: DataRow[] = [{ timestamp: '2026-04-01T08:00:00Z', shift: 'A', value: 100 }]; + const incoming: DataRow[] = [{ timestamp: '2026-04-02T08:00:00Z', shift: 'A', value: 102 }]; + const { merged, report } = mergeRows(existing, incoming, keyColumns); + expect(merged).toHaveLength(2); + expect(report).toEqual({ added: 1, duplicates: 0, corrected: 0 }); + }); + + it('drops exact duplicates', () => { + const existing: DataRow[] = [{ timestamp: '2026-04-01T08:00:00Z', shift: 'A', value: 100 }]; + const incoming: DataRow[] = [{ timestamp: '2026-04-01T08:00:00Z', shift: 'A', value: 100 }]; + const { merged, report } = mergeRows(existing, incoming, keyColumns); + expect(merged).toHaveLength(1); + expect(report.duplicates).toBe(1); + expect(report.added).toBe(0); + }); + + it('treats matching key + different value as a correction (newer wins)', () => { + const existing: DataRow[] = [{ timestamp: '2026-04-01T08:00:00Z', shift: 'A', value: 100 }]; + const incoming: DataRow[] = [{ timestamp: '2026-04-01T08:00:00Z', shift: 'A', value: 105 }]; + const keyColumnsExceptValue = ['timestamp', 'shift']; // value not in keys -> correction + const { merged, report } = mergeRows(existing, incoming, keyColumnsExceptValue); + expect(merged).toHaveLength(1); + expect(merged[0].value).toBe(105); + expect(report.corrected).toBe(1); + }); +}); diff --git a/packages/core/src/appendMode.ts b/packages/core/src/appendMode.ts new file mode 100644 index 000000000..a2b399221 --- /dev/null +++ b/packages/core/src/appendMode.ts @@ -0,0 +1,62 @@ +import type { DataRow } from './types'; + +export interface MergeReport { + added: number; + duplicates: number; + corrected: number; +} + +export interface MergeResult { + merged: DataRow[]; + report: MergeReport; +} + +/** + * Merge incoming rows into existing rows. + * + * `keyColumns` identifies the set of columns that determine row identity. + * - Match on all keyColumns + all other column values -> exact duplicate (dropped) + * - Match on keyColumns, differ on other values -> correction (incoming wins) + * - No match on keyColumns -> append + * + * Per docs/superpowers/plans/2026-04-29-multi-level-scout-v1-decisions.md decision #4. + */ +export function mergeRows( + existing: DataRow[], + incoming: DataRow[], + keyColumns: string[] +): MergeResult { + const keyOf = (row: DataRow): string => keyColumns.map(c => String(row[c] ?? '')).join('||'); + + const existingByKey = new Map(); + existing.forEach((row, index) => existingByKey.set(keyOf(row), { row, index })); + + const merged: DataRow[] = [...existing]; + let added = 0; + let duplicates = 0; + let corrected = 0; + + for (const newRow of incoming) { + const key = keyOf(newRow); + const match = existingByKey.get(key); + + if (!match) { + merged.push(newRow); + added++; + continue; + } + + const isExactDuplicate = Object.keys(newRow).every( + col => String(match.row[col] ?? '') === String(newRow[col] ?? '') + ); + + if (isExactDuplicate) { + duplicates++; + } else { + merged[match.index] = newRow; + corrected++; + } + } + + return { merged, report: { added, duplicates, corrected } }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0229890c5..a08c515c8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -894,3 +894,6 @@ export * from './timeline'; // Investigation scope classification (Multi-level SCOUT V1) export { detectScope, type Scope } from './scopeDetection'; + +// Append-mode row-merge (Multi-level SCOUT V1) +export { mergeRows, type MergeReport, type MergeResult } from './appendMode'; From ad39bec8490fb8fe1a639050ee5b0f8a7f99d96b Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Wed, 29 Apr 2026 20:16:27 +0300 Subject: [PATCH 06/20] feat(core): computeFindingWindowDrift + WindowContext on Finding Implements Task 5 of multi-level SCOUT V1. Adds WindowContext type to Finding (optional, backward-compatible) and computeFindingWindowDrift() which compares stats-at-creation vs current-window stats using relative change with a default 0.20 threshold. 4 tests pass. Interleaved doc section added to statistics-reference.md. Co-Authored-By: ruflo --- docs/05-technical/statistics-reference.md | 34 +++++++++ .../core/src/findings/__tests__/drift.test.ts | 74 +++++++++++++++++++ packages/core/src/findings/drift.ts | 55 ++++++++++++++ packages/core/src/findings/index.ts | 3 + packages/core/src/findings/types.ts | 21 ++++++ 5 files changed, 187 insertions(+) create mode 100644 packages/core/src/findings/__tests__/drift.test.ts create mode 100644 packages/core/src/findings/drift.ts diff --git a/docs/05-technical/statistics-reference.md b/docs/05-technical/statistics-reference.md index b114572ff..74649a763 100644 --- a/docs/05-technical/statistics-reference.md +++ b/docs/05-technical/statistics-reference.md @@ -731,6 +731,40 @@ Findings connect to charts via `FindingSource` metadata: - Idea management (`addIdea`, `updateIdea`, `removeIdea`) - `setIdeaProjection`, `selectIdea` for What-If integration +### Finding Window Drift + +> Source: `packages/core/src/findings/drift.ts`. Spec §3.5 / ADR-049. + +Drift detection compares a Finding's stats at creation time to current-window stats: + +``` +relativeChange = (currentVal − beforeVal) / beforeVal +drifted = |relativeChange| ≥ threshold (default threshold = 0.20) +``` + +**Metric priority**: Cpk is used as the primary drift signal; falls back to mean if Cpk is absent, then sigma. + +**Per-finding override**: `WindowContext.driftThreshold` overrides the default 0.20. Use for high-stakes findings that need tighter alerting. + +**Returns `null`** when a Finding has no `windowContext` (created before V1 multi-level SCOUT). This is the backward-compatible path; callers must guard `result !== null` before reading `drifted`. + +**`WindowContext` shape** (stored on `Finding.windowContext`): + +| Field | Type | Description | +| ------------------ | ---------------------------- | ------------------------------------------- | +| `windowAtCreation` | `TimelineWindow` | Active window when the finding was captured | +| `statsAtCreation` | `{ cpk?, mean?, sigma?, n }` | Key stats at capture time | +| `driftThreshold` | `number` (optional) | Relative-change threshold override (0–1) | + +**`DriftResult` shape**: + +| Field | Type | Description | +| ---------------- | ---------------------------- | ------------------------------------------ | +| `drifted` | `boolean` | Whether threshold was exceeded | +| `relativeChange` | `number` | Signed relative change (positive = higher) | +| `metric` | `'cpk' \| 'mean' \| 'sigma'` | Which metric was used for comparison | +| `threshold` | `number` | Threshold applied (default or override) | + --- ## Part 15 — Unified GLM Regression Engine diff --git a/packages/core/src/findings/__tests__/drift.test.ts b/packages/core/src/findings/__tests__/drift.test.ts new file mode 100644 index 000000000..5c9de7aa5 --- /dev/null +++ b/packages/core/src/findings/__tests__/drift.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { computeFindingWindowDrift } from '../drift'; +import type { Finding, WindowContext } from '../types'; + +// Minimal valid FindingContext for test mocks +const stubContext: Finding['context'] = { + activeFilters: {}, + cumulativeScope: null, +}; + +const makeFinding = (atCreation: WindowContext['statsAtCreation']): Finding => ({ + id: 'f1', + text: 'Test finding', + createdAt: Date.now(), + context: stubContext, + status: 'observed', + comments: [], + statusChangedAt: Date.now(), + windowContext: { + windowAtCreation: { + kind: 'fixed', + startISO: '2026-03-01T00:00:00Z', + endISO: '2026-03-31T23:59:59Z', + }, + statsAtCreation: atCreation, + }, +}); + +describe('computeFindingWindowDrift', () => { + it('returns no-drift when stats are identical', () => { + const finding = makeFinding({ cpk: 1.2, mean: 50, sigma: 2, n: 200 }); + const result = computeFindingWindowDrift(finding, { cpk: 1.2, mean: 50, sigma: 2, n: 200 }); + expect(result?.drifted).toBe(false); + expect(result?.relativeChange ?? 0).toBeCloseTo(0, 5); + }); + + it('flags drift when Cpk relative change exceeds threshold', () => { + const finding = makeFinding({ cpk: 1.0, n: 200 }); + const result = computeFindingWindowDrift(finding, { cpk: 0.7, n: 200 }); + expect(result?.drifted).toBe(true); + expect(result?.relativeChange).toBeCloseTo(-0.3, 2); + }); + + it('respects per-finding threshold override', () => { + const finding: Finding = { + ...makeFinding({ cpk: 1.0, n: 200 }), + windowContext: { + windowAtCreation: { + kind: 'fixed', + startISO: '2026-03-01T00:00:00Z', + endISO: '2026-03-31T23:59:59Z', + }, + statsAtCreation: { cpk: 1.0, n: 200 }, + driftThreshold: 0.05, + }, + }; + const result = computeFindingWindowDrift(finding, { cpk: 0.95, n: 200 }); + expect(result?.drifted).toBe(true); // 5% change at 5% threshold = drifted + }); + + it('returns null when finding has no windowContext', () => { + const finding: Finding = { + id: 'f1', + text: 'no ctx', + createdAt: Date.now(), + context: stubContext, + status: 'observed', + comments: [], + statusChangedAt: Date.now(), + }; + const result = computeFindingWindowDrift(finding, { cpk: 0.5, n: 100 }); + expect(result).toBeNull(); + }); +}); diff --git a/packages/core/src/findings/drift.ts b/packages/core/src/findings/drift.ts new file mode 100644 index 000000000..ce506c6e8 --- /dev/null +++ b/packages/core/src/findings/drift.ts @@ -0,0 +1,55 @@ +import type { Finding, WindowContext } from './types'; + +export interface DriftResult { + drifted: boolean; + relativeChange: number; + metric: 'cpk' | 'mean' | 'sigma'; + threshold: number; +} + +const DEFAULT_DRIFT_THRESHOLD = 0.2; + +/** + * Compare a Finding's stats-at-creation against current-window stats. + * + * Uses Cpk relative change as the primary drift signal; falls back to mean + * if Cpk is missing, then sigma. + * + * Returns null if the finding has no windowContext (i.e. created before V1). + * + * Per spec §3.5 (metric layer integration) + ADR-049 alignment. + * + * Formula: + * relativeChange = (currentVal − beforeVal) / beforeVal + * drifted = |relativeChange| ≥ threshold (default 0.20) + */ +export function computeFindingWindowDrift( + finding: Finding, + currentStats: WindowContext['statsAtCreation'] +): DriftResult | null { + const ctx = finding.windowContext; + if (!ctx) return null; + + const threshold = ctx.driftThreshold ?? DEFAULT_DRIFT_THRESHOLD; + const before = ctx.statsAtCreation; + + // Prefer cpk; fall back to mean, then sigma. + const metric: 'cpk' | 'mean' | 'sigma' = + before.cpk != null && currentStats.cpk != null + ? 'cpk' + : before.mean != null && currentStats.mean != null + ? 'mean' + : 'sigma'; + + const beforeVal = before[metric]; + const currentVal = currentStats[metric]; + + if (beforeVal == null || currentVal == null || beforeVal === 0) { + return { drifted: false, relativeChange: 0, metric, threshold }; + } + + const relativeChange = (currentVal - beforeVal) / beforeVal; + const drifted = Math.abs(relativeChange) >= threshold; + + return { drifted, relativeChange, metric, threshold }; +} diff --git a/packages/core/src/findings/index.ts b/packages/core/src/findings/index.ts index 02b26901f..d1306e8d8 100644 --- a/packages/core/src/findings/index.ts +++ b/packages/core/src/findings/index.ts @@ -49,3 +49,6 @@ export { projectMechanismBranch, projectMechanismBranches } from './mechanismBra // from this sub-path to avoid a duplicate identifier at the root barrel. The evaluator // accepts `Record`-compatible rows; consumers get the canonical DataRow // via `@variscout/core` root or `@variscout/core/types` directly. +export { computeFindingWindowDrift } from './drift'; +export type { DriftResult } from './drift'; +// WindowContext is already re-exported via `export * from './types'` above. diff --git a/packages/core/src/findings/types.ts b/packages/core/src/findings/types.ts index 9cbc26eca..938a8a310 100644 --- a/packages/core/src/findings/types.ts +++ b/packages/core/src/findings/types.ts @@ -4,6 +4,7 @@ */ import type { HypothesisCondition } from './hypothesisCondition'; +import type { TimelineWindow } from '../timeline'; // ============================================================================ // Investigation Status Types @@ -445,6 +446,24 @@ export type FindingSource = | { chart: 'yamazumi'; category: string; activityType?: string } | { chart: 'coscout'; messageId: string }; +// ============================================================================ +// Window Context (multi-level SCOUT V1 — drift detection) +// ============================================================================ + +/** + * Snapshot of the timeline window and key stats at the moment a Finding was created. + * Used by `computeFindingWindowDrift` to detect when current-window stats diverge + * from the context in which the finding was made. + */ +export interface WindowContext { + /** The active timeline window when the finding was created */ + windowAtCreation: TimelineWindow; + /** Key stats captured at creation time */ + statsAtCreation: { cpk?: number; mean?: number; sigma?: number; n: number }; + /** Override the default drift threshold (0.20). Useful for high-stakes findings. */ + driftThreshold?: number; +} + // ============================================================================ // Finding Types // ============================================================================ @@ -514,6 +533,8 @@ export interface Finding { scoped?: boolean; /** Whether this finding's text annotation should be visible on the chart. Default: undefined (shown for backward compat). */ showOnChart?: boolean; + /** Timeline window + stats snapshot at creation time. Used for drift detection (V1 multi-level SCOUT). Absent for findings created before V1. */ + windowContext?: WindowContext; } // ============================================================================ From c3642f25f78c49d1909e02820e87b36264a0844f Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Wed, 29 Apr 2026 20:21:45 +0300 Subject: [PATCH 07/20] =?UTF-8?q?feat(core):=20throughput=20module=20?= =?UTF-8?q?=E2=80=94=20computeOutputRate=20+=20computeBottleneck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds packages/core/src/throughput/ mirroring defect/yamazumi shape. computeOutputRate buckets rows by time granularity and emits ratePerHour; computeBottleneck ranks steps by rate and flags the slowest. 3/3 tests pass. Re-exported from core barrel; statistics-reference Part 17 added. Co-Authored-By: ruflo --- docs/05-technical/statistics-reference.md | 33 +++++++++ packages/core/src/index.ts | 3 + .../throughput/__tests__/aggregation.test.ts | 49 +++++++++++++ packages/core/src/throughput/aggregation.ts | 70 +++++++++++++++++++ packages/core/src/throughput/index.ts | 3 + packages/core/src/throughput/types.ts | 23 ++++++ 6 files changed, 181 insertions(+) create mode 100644 packages/core/src/throughput/__tests__/aggregation.test.ts create mode 100644 packages/core/src/throughput/aggregation.ts create mode 100644 packages/core/src/throughput/index.ts create mode 100644 packages/core/src/throughput/types.ts diff --git a/docs/05-technical/statistics-reference.md b/docs/05-technical/statistics-reference.md index 74649a763..03698d671 100644 --- a/docs/05-technical/statistics-reference.md +++ b/docs/05-technical/statistics-reference.md @@ -1055,3 +1055,36 @@ Raw Data → [B1: Input] → Clean Data → [Stats Engine] → [B2: Output] → ### Convention Stats functions return `number | undefined` (or `null` for ANOVA), never `NaN` or `Infinity`. The single exception is `andersonDarlingTest()` which returns `Infinity` intentionally for degenerate data. + +--- + +## Part 17 — Throughput Metrics + +### `computeOutputRate(rows, timeColumn, { nodeId, stepColumn }, granularity)` + +Counts rows per time bucket for one step and computes rate-per-hour: + +``` +bucketStart = floor(t / bucketMs) × bucketMs +bucketCount = |{ rows : t ∈ [bucketStart, bucketStart + bucketMs) ∧ row.step = nodeId }| +ratePerHour = (bucketCount × MS_PER_HOUR) / bucketMs +averageRatePerHour = mean(ratePerHour over all buckets) +``` + +Granularities: minute (60 s), hour (3 600 s), day (86 400 s), week (604 800 s). + +Returns `OutputRateResult` — `totalCount` is the raw row count for the step; `averageRatePerHour` is 0 when no rows are present. + +### `computeBottleneck(rates)` + +Identifies the step with the lowest average rate as the bottleneck: + +``` +bottleneck = argmin_step(averageRatePerHour) +rank = position of step in sorted-ascending-by-rate order (1 = slowest) +isBottleneck = (step.nodeId == bottleneck.nodeId) +``` + +Input is a `ReadonlyArray<{ nodeId, averageRatePerHour }>` — typically the `averageRatePerHour` values from one `computeOutputRate` call per step. Returns `BottleneckResult[]` sorted ascending by rank. + +Source: `packages/core/src/throughput/aggregation.ts`. Per spec §3.5 (L2 Flow metrics). diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a08c515c8..5b40745ba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -897,3 +897,6 @@ export { detectScope, type Scope } from './scopeDetection'; // Append-mode row-merge (Multi-level SCOUT V1) export { mergeRows, type MergeReport, type MergeResult } from './appendMode'; + +// Throughput metrics (Multi-level SCOUT V1) +export * from './throughput'; diff --git a/packages/core/src/throughput/__tests__/aggregation.test.ts b/packages/core/src/throughput/__tests__/aggregation.test.ts new file mode 100644 index 000000000..d9f60b21f --- /dev/null +++ b/packages/core/src/throughput/__tests__/aggregation.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { computeOutputRate, computeBottleneck } from '../aggregation'; +import type { DataRow } from '../../types'; + +describe('computeOutputRate', () => { + it('counts rows per bucket and computes rate-per-hour', () => { + const rows: DataRow[] = [ + { timestamp: '2026-04-29T08:00:00Z', step: 'roast' }, + { timestamp: '2026-04-29T08:30:00Z', step: 'roast' }, + { timestamp: '2026-04-29T09:00:00Z', step: 'roast' }, + { timestamp: '2026-04-29T09:30:00Z', step: 'roast' }, + ]; + const result = computeOutputRate( + rows, + 'timestamp', + { nodeId: 'roast', stepColumn: 'step' }, + 'hour' + ); + expect(result.totalCount).toBe(4); + expect(result.buckets.length).toBe(2); + expect(result.buckets[0].ratePerHour).toBe(2); + expect(result.buckets[1].ratePerHour).toBe(2); + expect(result.averageRatePerHour).toBe(2); + }); + + it('returns zero rate for empty input', () => { + const result = computeOutputRate( + [], + 'timestamp', + { nodeId: 'roast', stepColumn: 'step' }, + 'hour' + ); + expect(result.totalCount).toBe(0); + expect(result.averageRatePerHour).toBe(0); + }); +}); + +describe('computeBottleneck', () => { + it('identifies the lowest rate as the bottleneck', () => { + const rates: ReadonlyArray<{ nodeId: string; averageRatePerHour: number }> = [ + { nodeId: 'roast', averageRatePerHour: 60 }, + { nodeId: 'grind', averageRatePerHour: 30 }, + { nodeId: 'pack', averageRatePerHour: 80 }, + ]; + const result = computeBottleneck(rates); + expect(result.find(r => r.nodeId === 'grind')!.isBottleneck).toBe(true); + expect(result.find(r => r.nodeId === 'roast')!.isBottleneck).toBe(false); + }); +}); diff --git a/packages/core/src/throughput/aggregation.ts b/packages/core/src/throughput/aggregation.ts new file mode 100644 index 000000000..cce46d98e --- /dev/null +++ b/packages/core/src/throughput/aggregation.ts @@ -0,0 +1,70 @@ +import type { DataRow } from '../types'; +import { parseTimeValue } from '../time'; +import type { OutputRateResult, OutputRateBucket, BottleneckResult } from './types'; + +export interface OutputRateInput { + nodeId: string; + stepColumn: string; // column in DataRow that names the step +} + +const MS_PER_HOUR = 60 * 60 * 1000; +const GRANULARITY_MS: Record = { + minute: 60 * 1000, + hour: MS_PER_HOUR, + day: 24 * MS_PER_HOUR, + week: 7 * 24 * MS_PER_HOUR, +}; + +export function computeOutputRate( + rows: DataRow[], + timeColumn: string, + input: OutputRateInput, + granularity: 'minute' | 'hour' | 'day' | 'week' +): OutputRateResult { + const stepRows = rows.filter(r => String(r[input.stepColumn]) === input.nodeId); + const bucketMs = GRANULARITY_MS[granularity]; + + const bucketMap = new Map(); + for (const r of stepRows) { + const t = parseTimeValue(r[timeColumn]); + if (!t) continue; + const bucketStart = Math.floor(t.getTime() / bucketMs) * bucketMs; + bucketMap.set(bucketStart, (bucketMap.get(bucketStart) ?? 0) + 1); + } + + const buckets: OutputRateBucket[] = [...bucketMap.entries()] + .sort(([a], [b]) => a - b) + .map(([startMs, count]) => ({ + bucketStartISO: new Date(startMs).toISOString(), + bucketEndISO: new Date(startMs + bucketMs).toISOString(), + count, + ratePerHour: (count * MS_PER_HOUR) / bucketMs, + })); + + const totalCount = stepRows.length; + const averageRatePerHour = + buckets.length === 0 ? 0 : buckets.reduce((s, b) => s + b.ratePerHour, 0) / buckets.length; + + return { + nodeId: input.nodeId, + granularity, + buckets, + totalCount, + averageRatePerHour, + }; +} + +export function computeBottleneck( + rates: ReadonlyArray<{ nodeId: string; averageRatePerHour: number }> +): BottleneckResult[] { + const sorted = [...rates].sort((a, b) => a.averageRatePerHour - b.averageRatePerHour); + const bottleneckNodeId = sorted[0]?.nodeId; + return rates + .map(r => ({ + nodeId: r.nodeId, + averageRatePerHour: r.averageRatePerHour, + rank: sorted.findIndex(s => s.nodeId === r.nodeId) + 1, + isBottleneck: r.nodeId === bottleneckNodeId, + })) + .sort((a, b) => a.rank - b.rank); +} diff --git a/packages/core/src/throughput/index.ts b/packages/core/src/throughput/index.ts new file mode 100644 index 000000000..b5c8f7de6 --- /dev/null +++ b/packages/core/src/throughput/index.ts @@ -0,0 +1,3 @@ +export type { OutputRateResult, OutputRateBucket, BottleneckResult } from './types'; +export { computeOutputRate, computeBottleneck } from './aggregation'; +export type { OutputRateInput } from './aggregation'; diff --git a/packages/core/src/throughput/types.ts b/packages/core/src/throughput/types.ts new file mode 100644 index 000000000..23e54fd1c --- /dev/null +++ b/packages/core/src/throughput/types.ts @@ -0,0 +1,23 @@ +import type { TimeGranularity } from '../time'; + +export interface OutputRateBucket { + bucketStartISO: string; + bucketEndISO: string; + count: number; + ratePerHour: number; +} + +export interface OutputRateResult { + nodeId: string; + granularity: TimeGranularity; + buckets: OutputRateBucket[]; + totalCount: number; + averageRatePerHour: number; +} + +export interface BottleneckResult { + nodeId: string; + averageRatePerHour: number; + rank: number; + isBottleneck: boolean; // lowest rate among the analysed steps +} From f303666dcb8564129bbf0845cac479adfc2d4518 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Wed, 29 Apr 2026 20:27:14 +0300 Subject: [PATCH 08/20] feat(core): AnalysisModeStrategy gains dataRouter for (scope, phase, window) routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds RouterScope, RouterPhase, RouterArgs, RouterResult, RouterHook and ChartSlots types to analysisStrategy.ts. Extends AnalysisModeStrategy with an optional dataRouter field (backward compat). All 5 registered strategies implement it with locked routing decisions: investigation→useFilteredData, hub→useProductionLineGlanceData (where supported). Process Flow mode has no strategy registration in V1. Co-Authored-By: ruflo --- .../core/src/__tests__/dataRouter.test.ts | 85 +++++++++++++++++++ packages/core/src/analysisStrategy.ts | 70 +++++++++++++-- 2 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/__tests__/dataRouter.test.ts diff --git a/packages/core/src/__tests__/dataRouter.test.ts b/packages/core/src/__tests__/dataRouter.test.ts new file mode 100644 index 000000000..9bc13c221 --- /dev/null +++ b/packages/core/src/__tests__/dataRouter.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { getStrategy, resolveMode } from '../analysisStrategy'; +import type { RouterArgs, ResolvedMode } from '../analysisStrategy'; + +const baseArgs: RouterArgs = { + scope: 'b1', + phase: 'investigation', + window: { kind: 'cumulative' }, + context: {}, +}; + +describe('AnalysisModeStrategy.dataRouter', () => { + it('returns useFilteredData for standard mode in investigation phase', () => { + const strategy = getStrategy(resolveMode('standard')); + const result = strategy.dataRouter!(baseArgs); + expect(result.hook).toBe('useFilteredData'); + }); + + it('returns empty transforms for standard mode in investigation phase', () => { + const strategy = getStrategy(resolveMode('standard')); + const result = strategy.dataRouter!(baseArgs); + expect(result.transforms).toEqual([]); + }); + + it('returns useProductionLineGlanceData for standard mode in hub phase', () => { + const strategy = getStrategy(resolveMode('standard')); + const result = strategy.dataRouter!({ ...baseArgs, phase: 'hub' }); + expect(result.hook).toBe('useProductionLineGlanceData'); + }); + + it('returns calculateNodeCapability transform for standard mode in hub phase', () => { + const strategy = getStrategy(resolveMode('standard')); + const result = strategy.dataRouter!({ ...baseArgs, phase: 'hub' }); + expect(result.transforms).toContain('calculateNodeCapability'); + }); + + it('returns useProductionLineGlanceData for capability mode in hub phase', () => { + const strategy = getStrategy(resolveMode('standard', { standardIChartMetric: 'capability' })); + const args: RouterArgs = { + scope: 'b1', + phase: 'hub', + window: { kind: 'rolling', windowDays: 7 }, + context: {}, + }; + const result = strategy.dataRouter!(args); + expect(result.hook).toBe('useProductionLineGlanceData'); + }); + + it('returns node-capability transforms for capability mode in hub phase', () => { + const strategy = getStrategy(resolveMode('standard', { standardIChartMetric: 'capability' })); + const result = strategy.dataRouter!({ ...baseArgs, phase: 'hub' }); + expect(result.transforms).toContain('calculateNodeCapability'); + expect(result.transforms).toContain('computeOutputRate'); + expect(result.transforms).toContain('computeBottleneck'); + }); + + it('returns calculateStats for capability mode in investigation phase', () => { + const strategy = getStrategy(resolveMode('standard', { standardIChartMetric: 'capability' })); + const result = strategy.dataRouter!(baseArgs); + expect(result.transforms).toContain('calculateStats'); + }); + + it('returns useFilteredData for yamazumi mode regardless of phase', () => { + const strategy = getStrategy(resolveMode('yamazumi')); + expect(strategy.dataRouter!({ ...baseArgs, phase: 'investigation' }).hook).toBe( + 'useFilteredData' + ); + expect(strategy.dataRouter!({ ...baseArgs, phase: 'hub' }).hook).toBe('useFilteredData'); + }); + + it('returns useFilteredData for defect mode', () => { + const strategy = getStrategy(resolveMode('defect')); + const result = strategy.dataRouter!(baseArgs); + expect(result.hook).toBe('useFilteredData'); + expect(result.transforms).toContain('computeDefectRates'); + }); + + it('every strategy defines a dataRouter', () => { + const modes: ResolvedMode[] = ['standard', 'capability', 'performance', 'yamazumi', 'defect']; + for (const m of modes) { + const strategy = getStrategy(m); + expect(strategy.dataRouter, `${m} strategy must have dataRouter`).toBeDefined(); + } + }); +}); diff --git a/packages/core/src/analysisStrategy.ts b/packages/core/src/analysisStrategy.ts index 371213390..0835d49c8 100644 --- a/packages/core/src/analysisStrategy.ts +++ b/packages/core/src/analysisStrategy.ts @@ -1,4 +1,5 @@ -import type { AnalysisMode } from './types'; +import type { AnalysisMode, SpecLookupContext } from './types'; +import type { TimelineWindow } from './timeline'; export type ResolvedMode = 'standard' | 'capability' | 'performance' | 'yamazumi' | 'defect'; @@ -31,13 +32,40 @@ export type ChartSlotType = | 'yamazumi-summary' | 'defect-summary'; +/** Named alias for the four chart slots — used by RouterResult.chartVariants. */ +export interface ChartSlots { + slot1: ChartSlotType; + slot2: ChartSlotType; + slot3: ChartSlotType; + slot4: ChartSlotType; +} + +// --------------------------------------------------------------------------- +// Multi-level SCOUT V1 — dataRouter types +// --------------------------------------------------------------------------- + +export type RouterScope = 'b0' | 'b1' | 'b2'; +export type RouterPhase = 'investigation' | 'hub'; + +export interface RouterArgs { + scope: RouterScope; + phase: RouterPhase; + window: TimelineWindow; + context: SpecLookupContext; +} + +export type RouterHook = 'useFilteredData' | 'useProductionLineGlanceData'; + +export interface RouterResult { + hook: RouterHook; + /** Names of transform functions to invoke (e.g. 'calculateNodeCapability'). */ + transforms?: ReadonlyArray; + /** Strategy-level chart-slot overrides; empty object means "use defaults". */ + chartVariants?: Partial>; +} + export interface AnalysisModeStrategy { - chartSlots: { - slot1: ChartSlotType; - slot2: ChartSlotType; - slot3: ChartSlotType; - slot4: ChartSlotType; - }; + chartSlots: ChartSlots; kpiComponent: ResolvedMode; reportTitle: string; reportSections: string[]; @@ -46,6 +74,8 @@ export interface AnalysisModeStrategy { aiChartInsightKeys: string[]; aiToolSet: 'standard' | 'performance' | 'yamazumi'; questionStrategy: QuestionStrategy; + /** V1 Multi-level SCOUT — optional for backward compat; all shipped strategies implement it. */ + dataRouter?: (args: RouterArgs) => RouterResult; } export function resolveMode( @@ -76,6 +106,10 @@ const strategies: Readonly> = { validationMethod: 'anova', questionFocus: 'Which factor explains most variation?', }, + dataRouter: ({ phase }) => ({ + hook: phase === 'hub' ? 'useProductionLineGlanceData' : 'useFilteredData', + transforms: phase === 'hub' ? ['calculateNodeCapability'] : [], + }), }, capability: { chartSlots: { slot1: 'capability-ichart', slot2: 'boxplot', slot3: 'pareto', slot4: 'stats' }, @@ -92,6 +126,13 @@ const strategies: Readonly> = { validationMethod: 'anovaWithSpecs', questionFocus: 'Which factor most affects Cpk?', }, + dataRouter: ({ phase }) => ({ + hook: phase === 'hub' ? 'useProductionLineGlanceData' : 'useFilteredData', + transforms: + phase === 'hub' + ? ['calculateNodeCapability', 'computeOutputRate', 'computeBottleneck'] + : ['calculateStats'], + }), }, performance: { chartSlots: { @@ -113,6 +154,13 @@ const strategies: Readonly> = { validationMethod: 'anova', questionFocus: 'Which channel performs worst?', }, + dataRouter: ({ phase }) => ({ + hook: phase === 'hub' ? 'useProductionLineGlanceData' : 'useFilteredData', + transforms: + phase === 'hub' + ? ['calculateChannelStats', 'calculateChannelPerformance'] + : ['calculateChannelStats'], + }), }, yamazumi: { chartSlots: { @@ -135,6 +183,10 @@ const strategies: Readonly> = { validationMethod: 'taktCompliance', questionFocus: 'Which step has the most waste?', }, + dataRouter: () => ({ + hook: 'useFilteredData', // hub-time yamazumi not built for V1 + transforms: ['aggregateYamazumiData', 'classifyYamazumi'], + }), }, defect: { chartSlots: { slot1: 'ichart', slot2: 'boxplot', slot3: 'pareto', slot4: 'defect-summary' }, @@ -152,6 +204,10 @@ const strategies: Readonly> = { validationMethod: 'anova', questionFocus: 'Which defect type dominates and which factor drives defect rate variation?', }, + dataRouter: () => ({ + hook: 'useFilteredData', + transforms: ['computeDefectRates'], + }), }, }; From cf5daaa617654759b2f3119d706d0eceda3bca2b Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Thu, 30 Apr 2026 00:10:10 +0300 Subject: [PATCH 09/20] docs: revise Task 8 to pure-projection over investigation metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original Task 8 spec invented a 5th module-level Zustand store in @variscout/hooks keyed by investigationId. Three issues with that: 1. Wrong layer — CLAUDE.md invariant locks 4 domain Zustand stores; window selection is viewer state, not a 5th domain store. 2. Memory leak by design — module-level Map has no eviction in a long-lived PWA session. 3. Decision #1 already located the window on ProcessHubInvestigationMetadata alongside nodeMappings (see commit f059c591). The original plan's parallel cache ignored that, same class of mismatch the Decision #1 revision already corrected once. Revised: useTimelineWindow becomes a thin pure projection. Caller passes the investigation envelope and an onChange callback wired to its existing persistInvestigation flow (canonical pattern at apps/azure/src/features/ processHub/useHubMigrationState.ts:67-114). Window persists where the investigation persists (IndexedDB / Blob per ADR-059). No new Zustand store, no zustand dep added to @variscout/hooks, package flow stays clean. Adds timelineWindow?: TimelineWindow to ProcessHubInvestigationMetadata in core (the type-level half of Decision #1's intent that was previously documented but not yet wired into the type). Note for Tasks 11/14: app-level wiring now hangs onChange off persistInvestigation alongside the nodeMappings flow. Co-Authored-By: ruflo --- .../plans/2026-04-29-multi-level-scout-v1.md | 151 ++++++++++++------ 1 file changed, 103 insertions(+), 48 deletions(-) diff --git a/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md b/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md index 712313073..5628f4d0e 100644 --- a/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md +++ b/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md @@ -1221,98 +1221,137 @@ git commit -m "feat(core): AnalysisModeStrategy gains dataRouter for (scope, pha ## Task 8: useTimelineWindow hook +**Revised 2026-04-30** (supersedes the original Zustand-store-in-hooks design). The original plan invented a 5th module-level Zustand store keyed by `investigationId` to hold the user's window choice. Three problems with that: + +1. **Wrong architectural layer.** CLAUDE.md invariant: "4 domain Zustand stores are source of truth." A window selector is _viewer state_ over investigation metadata, not a 5th domain store. +2. **Memory leak by design.** A module-level `Map` has no eviction — every visited investigation leaves a record forever in PWA module state. +3. **Decision #1 already located the window.** Commit `f059c591` revised Decision #1 to put `TimelineWindow` on `ProcessHubInvestigationMetadata` — co-located with `nodeMappings`, on the investigation envelope itself. Investigation envelopes are persisted by apps (Dexie + Blob in Azure, similar in PWA) via `persistInvestigation`. The original plan's parallel cache ignored that. + +**Revised design**: `useTimelineWindow` becomes a thin pure projection over the investigation envelope passed in by the caller. Persistence is the caller's responsibility (typically wired to the same `persistInvestigation` flow that already handles `nodeMappings` and `migrationDeclinedAt` — see `apps/azure/src/features/processHub/useHubMigrationState.ts:67-114` for the existing pattern). No new Zustand store, no module-level cache, no zustand dep added to `@variscout/hooks` — package flow stays clean (core → hooks → ui → apps). + +The window persists exactly where the investigation persists (IndexedDB / Blob per ADR-059). User reopens tomorrow, choice is there. + **Files:** +- Modify: `packages/core/src/processHub.ts` — add `timelineWindow?: TimelineWindow` to `ProcessHubInvestigationMetadata` (alongside `nodeMappings` per Decision #1). Import `TimelineWindow` via `import type` from the timeline module. - Create: `packages/hooks/src/useTimelineWindow.ts` - Create: `packages/hooks/src/__tests__/useTimelineWindow.test.ts` - Modify: `packages/hooks/src/index.ts` -- [ ] **Step 8.1: Write failing test** +- [ ] **Step 8.1: Add `timelineWindow` to metadata type** + +In `packages/core/src/processHub.ts`, in the `ProcessHubInvestigationMetadata` interface (after `nodeMappings`): + +```typescript + /** + * Optional timeline window applied to this investigation's data when + * computing findings/charts. Co-located with nodeMappings per Decision #1 + * (see docs/superpowers/plans/2026-04-29-multi-level-scout-v1-decisions.md). + * Absent → callers should use the mode's default window (typically `cumulative`). + */ + timelineWindow?: TimelineWindow; +``` + +Add at the top of the file: `import type { TimelineWindow } from './timeline';` (uses the existing timeline barrel — no cycle: timeline imports nothing from processHub). + +- [ ] **Step 8.2: Write failing test** ```typescript // packages/hooks/src/__tests__/useTimelineWindow.test.ts -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { renderHook, act } from '@testing-library/react'; +import type { ProcessHubInvestigation } from '@variscout/core'; import { useTimelineWindow } from '../useTimelineWindow'; +const inv = ( + id: string, + metadata?: ProcessHubInvestigation['metadata'] +): Pick => ({ id, metadata }); + describe('useTimelineWindow', () => { - it('initializes with cumulative when no investigation context', () => { + it('returns cumulative default when metadata.timelineWindow is absent', () => { + const onChange = vi.fn(); const { result } = renderHook(() => - useTimelineWindow({ investigationId: 'inv-1', defaultKind: 'cumulative' }) + useTimelineWindow({ investigation: inv('inv-1'), onChange }) ); - expect(result.current.window.kind).toBe('cumulative'); + expect(result.current.window).toEqual({ kind: 'cumulative' }); }); - it('switches to rolling and persists', () => { - const { result } = renderHook(() => useTimelineWindow({ investigationId: 'inv-1' })); - act(() => { - result.current.setWindow({ kind: 'rolling', windowDays: 30 }); - }); + it('reflects the metadata.timelineWindow when present', () => { + const onChange = vi.fn(); + const { result } = renderHook(() => + useTimelineWindow({ + investigation: inv('inv-1', { timelineWindow: { kind: 'rolling', windowDays: 30 } }), + onChange, + }) + ); expect(result.current.window).toEqual({ kind: 'rolling', windowDays: 30 }); }); + + it('setWindow delegates to onChange with investigationId', () => { + const onChange = vi.fn(); + const { result } = renderHook(() => + useTimelineWindow({ investigation: inv('inv-1'), onChange }) + ); + act(() => result.current.setWindow({ kind: 'rolling', windowDays: 7 })); + expect(onChange).toHaveBeenCalledWith('inv-1', { kind: 'rolling', windowDays: 7 }); + }); }); ``` -- [ ] **Step 8.2: Implement (Zustand-backed, URL-synced)** +- [ ] **Step 8.3: Run test, verify it fails** + +```bash +pnpm --filter @variscout/hooks test -- useTimelineWindow +``` + +Expected: FAIL — module not found. + +- [ ] **Step 8.4: Implement (pure projection)** ```typescript // packages/hooks/src/useTimelineWindow.ts -import { useEffect, useCallback, useMemo } from 'react'; -import { create } from 'zustand'; -import type { TimelineWindow } from '@variscout/core'; +import { useCallback, useMemo } from 'react'; +import type { ProcessHubInvestigation, TimelineWindow } from '@variscout/core'; -interface WindowStore { - windows: Record; // keyed by investigationId - setWindow: (id: string, w: TimelineWindow) => void; -} - -const useWindowStore = create(set => ({ - windows: {}, - setWindow: (id, w) => set(s => ({ windows: { ...s.windows, [id]: w } })), -})); +const DEFAULT_CUMULATIVE: TimelineWindow = { kind: 'cumulative' }; export interface UseTimelineWindowArgs { - investigationId: string; - defaultKind?: TimelineWindow['kind']; + /** Investigation envelope — only `id` and `metadata.timelineWindow` are read. */ + investigation: Pick; + /** + * Persistence callback. Caller wires this to its existing + * `persistInvestigation` flow (see apps/azure/src/features/processHub/ + * useHubMigrationState.ts for the canonical pattern). Receives + * `investigationId` so the same callback can serve many investigations. + */ + onChange: (investigationId: string, window: TimelineWindow) => void; } export interface UseTimelineWindowResult { window: TimelineWindow; - setWindow: (w: TimelineWindow) => void; + setWindow: (window: TimelineWindow) => void; } -const DEFAULT_WINDOW_BY_KIND: Record< - NonNullable, - TimelineWindow -> = { - fixed: { kind: 'fixed', startISO: '1970-01-01T00:00:00Z', endISO: new Date().toISOString() }, - rolling: { kind: 'rolling', windowDays: 30 }, - openEnded: { kind: 'openEnded', startISO: new Date().toISOString() }, - cumulative: { kind: 'cumulative' }, -}; - export function useTimelineWindow({ - investigationId, - defaultKind = 'cumulative', + investigation, + onChange, }: UseTimelineWindowArgs): UseTimelineWindowResult { - const stored = useWindowStore(s => s.windows[investigationId]); - const setStored = useWindowStore(s => s.setWindow); - const window = useMemo( - () => stored ?? DEFAULT_WINDOW_BY_KIND[defaultKind], - [stored, defaultKind] + () => investigation.metadata?.timelineWindow ?? DEFAULT_CUMULATIVE, + [investigation.metadata?.timelineWindow] ); const setWindow = useCallback( - (w: TimelineWindow) => setStored(investigationId, w), - [investigationId, setStored] + (w: TimelineWindow) => onChange(investigation.id, w), + [investigation.id, onChange] ); return { window, setWindow }; } ``` -- [ ] **Step 8.3: Re-export + run tests + commit** +- [ ] **Step 8.5: Re-export + run tests + commit** Add to `packages/hooks/src/index.ts`: @@ -1328,11 +1367,27 @@ export { pnpm --filter @variscout/hooks test -- useTimelineWindow ``` +Expected: 3 tests passed. Then run the full hooks suite to confirm no regressions: + ```bash -git add packages/hooks/src/useTimelineWindow.ts packages/hooks/src/__tests__/useTimelineWindow.test.ts packages/hooks/src/index.ts -git commit -m "feat(hooks): useTimelineWindow — Zustand-backed window state per investigation" +pnpm --filter @variscout/hooks test ``` +Type-check the dependent packages (`@variscout/hooks` consumes the new core type; `@variscout/ui` and `@variscout/azure-app` consume hooks): + +```bash +pnpm --filter @variscout/core build +pnpm --filter @variscout/hooks build +pnpm --filter @variscout/ui build +``` + +```bash +git add packages/core/src/processHub.ts packages/hooks/src/useTimelineWindow.ts packages/hooks/src/__tests__/useTimelineWindow.test.ts packages/hooks/src/index.ts +git commit -m "feat(hooks): useTimelineWindow — pure projection over investigation metadata" +``` + +**Note for Tasks 11/14 (FilterContextBar wiring + app-level wiring)**: those tasks now wire `onChange` to the app's `persistInvestigation` (the same flow that updates `nodeMappings`). The hooks package stays persistence-agnostic. + --- ## Task 9: useDataRouter hook From 4adae8b1e2cdbbd167d73d55b84f52a252adcc2f Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Thu, 30 Apr 2026 00:15:16 +0300 Subject: [PATCH 10/20] =?UTF-8?q?feat(hooks):=20useTimelineWindow=20?= =?UTF-8?q?=E2=80=94=20pure=20projection=20over=20investigation=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: ruflo --- packages/core/src/processHub.ts | 8 ++++ .../src/__tests__/useTimelineWindow.test.ts | 39 +++++++++++++++++++ packages/hooks/src/index.ts | 6 +++ packages/hooks/src/useTimelineWindow.ts | 38 ++++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 packages/hooks/src/__tests__/useTimelineWindow.test.ts create mode 100644 packages/hooks/src/useTimelineWindow.ts diff --git a/packages/core/src/processHub.ts b/packages/core/src/processHub.ts index 1d869791c..db19bdc42 100644 --- a/packages/core/src/processHub.ts +++ b/packages/core/src/processHub.ts @@ -13,6 +13,7 @@ import type { import type { HubReviewSignal } from './processReviewSignal'; import type { ProcessStateNote } from './processStateNote'; import type { SurveyStatus } from './survey/types'; +import type { TimelineWindow } from './timeline'; import type { SpecLimits } from './types'; import { isSustainmentDue, @@ -146,6 +147,13 @@ export interface ProcessHubInvestigationMetadata { * capability computation. See `InvestigationNodeMapping` above. */ nodeMappings?: InvestigationNodeMapping[]; + /** + * Optional timeline window applied to this investigation's data when + * computing findings/charts. Co-located with nodeMappings per Decision #1 + * (see docs/superpowers/plans/2026-04-29-multi-level-scout-v1-decisions.md). + * Absent → callers should use the mode's default window (typically `cumulative`). + */ + timelineWindow?: TimelineWindow; /** * ISO 8601 timestamp set when the analyst dismisses the B0 migration banner * for this investigation. Dismissed investigations remain B0; the banner diff --git a/packages/hooks/src/__tests__/useTimelineWindow.test.ts b/packages/hooks/src/__tests__/useTimelineWindow.test.ts new file mode 100644 index 000000000..f73e21920 --- /dev/null +++ b/packages/hooks/src/__tests__/useTimelineWindow.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import type { ProcessHubInvestigation } from '@variscout/core'; +import { useTimelineWindow } from '../useTimelineWindow'; + +const inv = ( + id: string, + metadata?: ProcessHubInvestigation['metadata'] +): Pick => ({ id, metadata }); + +describe('useTimelineWindow', () => { + it('returns cumulative default when metadata.timelineWindow is absent', () => { + const onChange = vi.fn(); + const { result } = renderHook(() => + useTimelineWindow({ investigation: inv('inv-1'), onChange }) + ); + expect(result.current.window).toEqual({ kind: 'cumulative' }); + }); + + it('reflects the metadata.timelineWindow when present', () => { + const onChange = vi.fn(); + const { result } = renderHook(() => + useTimelineWindow({ + investigation: inv('inv-1', { timelineWindow: { kind: 'rolling', windowDays: 30 } }), + onChange, + }) + ); + expect(result.current.window).toEqual({ kind: 'rolling', windowDays: 30 }); + }); + + it('setWindow delegates to onChange with investigationId', () => { + const onChange = vi.fn(); + const { result } = renderHook(() => + useTimelineWindow({ investigation: inv('inv-1'), onChange }) + ); + act(() => result.current.setWindow({ kind: 'rolling', windowDays: 7 })); + expect(onChange).toHaveBeenCalledWith('inv-1', { kind: 'rolling', windowDays: 7 }); + }); +}); diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index cc4eedeaa..d1dd2f6e4 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -529,3 +529,9 @@ export { usePerformanceAnalysis } from './usePerformanceAnalysis'; export { useYDomain, type YDomainResult } from './useYDomain'; export { useSpecsForMeasure } from './useSpecsForMeasure'; export { useProjectActions, type ProjectActionsResult } from './useProjectActions'; + +export { + useTimelineWindow, + type UseTimelineWindowArgs, + type UseTimelineWindowResult, +} from './useTimelineWindow'; diff --git a/packages/hooks/src/useTimelineWindow.ts b/packages/hooks/src/useTimelineWindow.ts new file mode 100644 index 000000000..5870cacad --- /dev/null +++ b/packages/hooks/src/useTimelineWindow.ts @@ -0,0 +1,38 @@ +import { useCallback, useMemo } from 'react'; +import type { ProcessHubInvestigation, TimelineWindow } from '@variscout/core'; + +const DEFAULT_CUMULATIVE: TimelineWindow = { kind: 'cumulative' }; + +export interface UseTimelineWindowArgs { + /** Investigation envelope — only `id` and `metadata.timelineWindow` are read. */ + investigation: Pick; + /** + * Persistence callback. Caller wires this to its existing + * `persistInvestigation` flow (see apps/azure/src/features/processHub/ + * useHubMigrationState.ts for the canonical pattern). Receives + * `investigationId` so the same callback can serve many investigations. + */ + onChange: (investigationId: string, window: TimelineWindow) => void; +} + +export interface UseTimelineWindowResult { + window: TimelineWindow; + setWindow: (window: TimelineWindow) => void; +} + +export function useTimelineWindow({ + investigation, + onChange, +}: UseTimelineWindowArgs): UseTimelineWindowResult { + const window = useMemo( + () => investigation.metadata?.timelineWindow ?? DEFAULT_CUMULATIVE, + [investigation.metadata?.timelineWindow] + ); + + const setWindow = useCallback( + (w: TimelineWindow) => onChange(investigation.id, w), + [investigation.id, onChange] + ); + + return { window, setWindow }; +} From ee345dd5aeecd410ab4a6432b80f9eb4066ff08e Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Thu, 30 Apr 2026 00:20:08 +0300 Subject: [PATCH 11/20] docs(hooks): JSDoc note re stable reference for investigation arg Per code-review nit on 4adae8b1: the returned `window` memoizes on `metadata.timelineWindow` identity, so a parent passing a fresh object literal each render would thrash the memo. Future Task 9 callers (useDataRouter) read `window` from this hook, so calling out the contract on the arg itself prevents the footgun. JSDoc-only; no behavior change. Co-Authored-By: ruflo --- packages/hooks/src/useTimelineWindow.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/hooks/src/useTimelineWindow.ts b/packages/hooks/src/useTimelineWindow.ts index 5870cacad..92947cb40 100644 --- a/packages/hooks/src/useTimelineWindow.ts +++ b/packages/hooks/src/useTimelineWindow.ts @@ -4,7 +4,13 @@ import type { ProcessHubInvestigation, TimelineWindow } from '@variscout/core'; const DEFAULT_CUMULATIVE: TimelineWindow = { kind: 'cumulative' }; export interface UseTimelineWindowArgs { - /** Investigation envelope — only `id` and `metadata.timelineWindow` are read. */ + /** + * Investigation envelope — only `id` and `metadata.timelineWindow` are read. + * Pass a stable reference (the actual stored investigation, not a fresh + * object literal each render); the returned `window` is memoized on + * `metadata.timelineWindow` identity, so a new object every render makes + * the memo thrash. + */ investigation: Pick; /** * Persistence callback. Caller wires this to its existing From bcebd5473f33cbfc34276a34971f8e55fcd3823c Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Thu, 30 Apr 2026 05:11:58 +0300 Subject: [PATCH 12/20] feat(hooks): useDataRouter + window-aware useFilteredData / useProductionLineGlanceData Co-Authored-By: ruflo --- .../hooks/src/__tests__/useDataRouter.test.ts | 76 +++++++++++++++++++ packages/hooks/src/index.ts | 10 ++- packages/hooks/src/useDataRouter.ts | 57 ++++++++++++++ packages/hooks/src/useFilteredData.ts | 69 ++++++++++++++++- .../hooks/src/useProductionLineGlanceData.ts | 70 +++++++++++++++-- 5 files changed, 270 insertions(+), 12 deletions(-) create mode 100644 packages/hooks/src/__tests__/useDataRouter.test.ts create mode 100644 packages/hooks/src/useDataRouter.ts diff --git a/packages/hooks/src/__tests__/useDataRouter.test.ts b/packages/hooks/src/__tests__/useDataRouter.test.ts new file mode 100644 index 000000000..68a6b7422 --- /dev/null +++ b/packages/hooks/src/__tests__/useDataRouter.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useDataRouter } from '../useDataRouter'; +import type { TimelineWindow } from '@variscout/core'; + +const cumulativeWindow: TimelineWindow = { kind: 'cumulative' }; + +describe('useDataRouter', () => { + it('routes investigation phase (standard mode) to useFilteredData with no transforms', () => { + const { result } = renderHook(() => + useDataRouter({ + mode: 'standard', + scope: 'b1', + phase: 'investigation', + window: cumulativeWindow, + context: {}, + }) + ); + expect(result.current.hook).toBe('useFilteredData'); + expect(result.current.transforms).toEqual([]); + }); + + it('routes hub phase (standard mode) to useProductionLineGlanceData with calculateNodeCapability', () => { + const { result } = renderHook(() => + useDataRouter({ + mode: 'standard', + scope: 'b1', + phase: 'hub', + window: cumulativeWindow, + context: {}, + }) + ); + expect(result.current.hook).toBe('useProductionLineGlanceData'); + expect(result.current.transforms).toContain('calculateNodeCapability'); + }); + + it('honors standardIChartMetric=capability via modeOptions to resolve capability strategy', () => { + const { result } = renderHook(() => + useDataRouter({ + mode: 'standard', + modeOptions: { standardIChartMetric: 'capability' }, + scope: 'b1', + phase: 'hub', + window: { kind: 'rolling', windowDays: 7 }, + context: {}, + }) + ); + expect(result.current.hook).toBe('useProductionLineGlanceData'); + // capability strategy adds output-rate + bottleneck transforms + expect(result.current.transforms).toContain('computeOutputRate'); + expect(result.current.transforms).toContain('computeBottleneck'); + }); + + it('routes yamazumi mode to useFilteredData regardless of phase', () => { + const { result: inv } = renderHook(() => + useDataRouter({ + mode: 'yamazumi', + scope: 'b1', + phase: 'investigation', + window: cumulativeWindow, + context: {}, + }) + ); + const { result: hub } = renderHook(() => + useDataRouter({ + mode: 'yamazumi', + scope: 'b1', + phase: 'hub', + window: cumulativeWindow, + context: {}, + }) + ); + expect(inv.current.hook).toBe('useFilteredData'); + expect(hub.current.hook).toBe('useFilteredData'); + }); +}); diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index d1dd2f6e4..8ae031829 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -503,6 +503,10 @@ export type { CapabilityBoxplotInputNode, } from './useProductionLineGlanceData'; +// Multi-level SCOUT V1 — useDataRouter (mode-strategy data-hook router) +export { useDataRouter } from './useDataRouter'; +export type { UseDataRouterArgs } from './useDataRouter'; + // Production Line Glance Filter (URL search-param state for filter strip) export { useProductionLineGlanceFilter } from './useProductionLineGlanceFilter'; export type { UseProductionLineGlanceFilterResult } from './useProductionLineGlanceFilter'; @@ -522,7 +526,11 @@ export type { } from './useB0InvestigationsInHub'; // Derived hooks (store-first state access) -export { useFilteredData, type FilteredDataResult } from './useFilteredData'; +export { + useFilteredData, + type FilteredDataResult, + type UseFilteredDataArgs, +} from './useFilteredData'; export { useAnalysisStats, type AnalysisStatsResult } from './useAnalysisStats'; export { useStagedAnalysis, type StagedAnalysisResult } from './useStagedAnalysis'; export { usePerformanceAnalysis } from './usePerformanceAnalysis'; diff --git a/packages/hooks/src/useDataRouter.ts b/packages/hooks/src/useDataRouter.ts new file mode 100644 index 000000000..97c217553 --- /dev/null +++ b/packages/hooks/src/useDataRouter.ts @@ -0,0 +1,57 @@ +/** + * useDataRouter — Multi-level SCOUT V1 router hook. + * + * Resolves the active analysis-mode strategy and asks its dataRouter which + * data hook (`useFilteredData` vs `useProductionLineGlanceData`) and which + * transforms apply for the given (scope × phase × window × context) tuple. + * + * The hook returns metadata only — callers are responsible for invoking the + * named hook themselves. This keeps useDataRouter cheap and avoids conditional + * hook calls (which React forbids). + */ + +import { useMemo } from 'react'; +import type { TimelineWindow } from '@variscout/core'; +import type { AnalysisMode, SpecLookupContext } from '@variscout/core/types'; +import { + getStrategy, + resolveMode, + type RouterArgs, + type RouterResult, + type RouterScope, + type RouterPhase, +} from '@variscout/core/strategy'; + +export interface UseDataRouterArgs { + mode: AnalysisMode; + /** Forwarded to resolveMode (e.g. `{ standardIChartMetric: 'capability' }`). */ + modeOptions?: { standardIChartMetric?: string }; + scope: RouterScope; + phase: RouterPhase; + window: TimelineWindow; + context: SpecLookupContext; +} + +const FALLBACK: RouterResult = { hook: 'useFilteredData' }; + +export function useDataRouter(args: UseDataRouterArgs): RouterResult { + const resolved = useMemo( + () => resolveMode(args.mode, args.modeOptions), + [args.mode, args.modeOptions] + ); + const strategy = useMemo(() => getStrategy(resolved), [resolved]); + + return useMemo(() => { + const router = strategy.dataRouter; + if (!router) return FALLBACK; + const routerArgs: RouterArgs = { + scope: args.scope, + phase: args.phase, + window: args.window, + context: args.context, + }; + const out = router(routerArgs); + // Normalize: callers may rely on transforms being defined. + return { transforms: [], ...out }; + }, [strategy, args.scope, args.phase, args.window, args.context]); +} diff --git a/packages/hooks/src/useFilteredData.ts b/packages/hooks/src/useFilteredData.ts index c558421ed..5d65e735e 100644 --- a/packages/hooks/src/useFilteredData.ts +++ b/packages/hooks/src/useFilteredData.ts @@ -3,10 +3,16 @@ * * Returns filtered DataRow[] and a reverse index map (filteredIndex → rawIndex). * Replaces the inline filtering logic from useDataState. + * + * Multi-level SCOUT V1 — accepts an optional `window` arg. When supplied + * together with a `timeColumn` on the project store, rows whose time value + * falls outside the window are dropped AFTER filter-pass. The index map + * continues to satisfy `filteredData[i] === rawData[filteredIndexMap.get(i)!]`. */ import { useMemo } from 'react'; -import type { DataRow } from '@variscout/core'; +import type { DataRow, TimelineWindow } from '@variscout/core'; +import { parseTimeValue } from '@variscout/core/time'; import { useProjectStore } from '@variscout/stores'; export interface FilteredDataResult { @@ -15,15 +21,72 @@ export interface FilteredDataResult { filteredIndexMap: Map; } -export function useFilteredData(): FilteredDataResult { +export interface UseFilteredDataArgs { + /** Optional timeline window. Applied alongside column filters using projectStore.timeColumn. */ + window?: TimelineWindow; +} + +/** Returns true if rawData index `i` should pass the window. */ +function makeWindowPredicate( + rawData: DataRow[], + timeColumn: string | null, + window: TimelineWindow | undefined +): ((i: number) => boolean) | null { + if (!window || !timeColumn) return null; + if (rawData.length === 0) return null; + // If the time column is missing from the data shape, applyWindow returns [] + // (see applyWindow.ts). Mirror that behavior here so callers can't show + // stale rows when the FRAME-detected column has been dropped. + if (!(timeColumn in rawData[0])) return () => false; + + if (window.kind === 'cumulative') { + return (i: number) => parseTimeValue(rawData[i][timeColumn]) !== null; + } + + let startMs: number; + let endMs: number; + const now = Date.now(); + switch (window.kind) { + case 'fixed': + startMs = Date.parse(window.startISO); + endMs = Date.parse(window.endISO); + break; + case 'rolling': + endMs = now; + startMs = endMs - window.windowDays * 24 * 60 * 60 * 1000; + break; + case 'openEnded': + startMs = Date.parse(window.startISO); + endMs = now; + break; + default: { + const _exhaustive: never = window; + void _exhaustive; + return () => false; + } + } + + return (i: number) => { + const t = parseTimeValue(rawData[i][timeColumn]); + if (t === null) return false; + const ms = t.getTime(); + return ms >= startMs && ms <= endMs; + }; +} + +export function useFilteredData(args: UseFilteredDataArgs = {}): FilteredDataResult { const rawData = useProjectStore(s => s.rawData); const filters = useProjectStore(s => s.filters); + const timeColumn = useProjectStore(s => s.timeColumn); + const { window } = args; return useMemo(() => { const filtered: DataRow[] = []; const indexMap = new Map(); + const inWindow = makeWindowPredicate(rawData, timeColumn, window); for (let i = 0; i < rawData.length; i++) { + if (inWindow && !inWindow(i)) continue; const row = rawData[i]; const filterEntries = Object.entries(filters); const pass = filterEntries.every(([col, vals]) => { @@ -38,5 +101,5 @@ export function useFilteredData(): FilteredDataResult { } return { filteredData: filtered, filteredIndexMap: indexMap }; - }, [rawData, filters]); + }, [rawData, filters, timeColumn, window]); } diff --git a/packages/hooks/src/useProductionLineGlanceData.ts b/packages/hooks/src/useProductionLineGlanceData.ts index ae4b87f2a..1f95f396e 100644 --- a/packages/hooks/src/useProductionLineGlanceData.ts +++ b/packages/hooks/src/useProductionLineGlanceData.ts @@ -6,6 +6,7 @@ import { } from '@variscout/core/stats'; import type { NodeCapabilityResult } from '@variscout/core/stats'; import type { SpecLookupContext } from '@variscout/core/types'; +import { applyWindow } from '@variscout/core'; import type { DataRow, IChartDataPoint, @@ -13,6 +14,7 @@ import type { ProcessHub, ProcessHubInvestigation, ProcessHubInvestigationMetadata, + TimelineWindow, } from '@variscout/core'; const DEFAULT_CPK_TARGET = 1.33; @@ -23,6 +25,17 @@ export interface UseProductionLineGlanceDataInput { rowsByInvestigation: ReadonlyMap; contextFilter: SpecLookupContext; defectColumns?: readonly string[]; + /** + * Optional timeline window applied per-investigation. Each member can declare + * its own time column via `timeColumnByInvestigation`; investigations missing + * a time column pass through unwindowed (safer fail-mode — show all data + * rather than silently drop it). This per-investigation routing respects + * ADR-073: no aggregation across heterogeneous units, including time + * conventions. + */ + window?: TimelineWindow; + /** Map of investigation id → time column name. */ + timeColumnByInvestigation?: ReadonlyMap; } export interface CapabilityBoxplotInputNode { @@ -73,21 +86,47 @@ function rowMatchesFilter(row: DataRow, filter: SpecLookupContext): boolean { export function useProductionLineGlanceData( input: UseProductionLineGlanceDataInput ): UseProductionLineGlanceDataResult { - const { hub, members, rowsByInvestigation, contextFilter, defectColumns } = input; + const { + hub, + members, + rowsByInvestigation, + contextFilter, + defectColumns, + window, + timeColumnByInvestigation, + } = input; const map = hub.canonicalProcessMap; + // Apply timeline window per-investigation. Each investigation may have its + // own time column (or none); we never aggregate timestamps across them. + // Investigations without a known timeColumn pass through unwindowed. + const windowedRowsByInvestigation = useMemo>(() => { + if (!window) return rowsByInvestigation; + const out = new Map(); + for (const [invId, rows] of rowsByInvestigation) { + const tc = timeColumnByInvestigation?.get(invId); + if (!tc) { + out.set(invId, rows); + continue; + } + // applyWindow expects a mutable DataRow[]; cast to mutable view. + out.set(invId, applyWindow(rows as DataRow[], tc, window)); + } + return out; + }, [rowsByInvestigation, window, timeColumnByInvestigation]); + // Collect all filtered rows across members for context-value discovery. const allFilteredRows = useMemo(() => { const out: DataRow[] = []; for (const member of members) { if (member.metadata?.processHubId !== hub.id) continue; - const rows = rowsByInvestigation.get(member.id) ?? []; + const rows = windowedRowsByInvestigation.get(member.id) ?? []; for (const row of rows) { if (rowMatchesFilter(row, contextFilter)) out.push(row); } } return out; - }, [hub.id, members, rowsByInvestigation, contextFilter]); + }, [hub.id, members, windowedRowsByInvestigation, contextFilter]); // Per-node capability results — one entry per (node × first-matching-member) pair. const capabilityNodes = useMemo(() => { @@ -99,7 +138,7 @@ export function useProductionLineGlanceData( if (member.metadata?.processHubId !== hub.id) continue; const meta = member.metadata as ProcessHubInvestigationMetadata; if (!meta?.nodeMappings?.some(m => m.nodeId === node.id)) continue; - const rows = rowsByInvestigation.get(member.id) ?? []; + const rows = windowedRowsByInvestigation.get(member.id) ?? []; const filtered = rows.filter(r => rowMatchesFilter(r, contextFilter)); if (filtered.length === 0) continue; const result = calculateNodeCapability(node.id, { @@ -116,18 +155,33 @@ export function useProductionLineGlanceData( } } return results; - }, [map, members, rowsByInvestigation, contextFilter, hub.id, hub.contextColumns]); + }, [map, members, windowedRowsByInvestigation, contextFilter, hub.id, hub.contextColumns]); // Roll up step error counts. rollupStepErrors reads rows from member objects - // directly (duck-typed cast in core). Pass members as-is. + // directly (duck-typed cast in core). When a window is active we hand it + // shadow members whose `rows` field has been clipped per the same per- + // investigation window logic — so step-error counts honor the same temporal + // window as the capability boxplot, without rollupStepErrors needing to know + // about windows. + const windowedMembers = useMemo(() => { + if (!window) return members; + return members.map(member => { + const windowedRows = windowedRowsByInvestigation.get(member.id); + // Preserve all original member fields; swap rows only when we have + // a windowed view for it. + if (!windowedRows) return member; + return { ...member, rows: windowedRows } as ProcessHubInvestigation; + }); + }, [members, window, windowedRowsByInvestigation]); + const errorSteps = useMemo(() => { return rollupStepErrors({ hub, - members, + members: windowedMembers, defectColumns, contextFilter, }); - }, [hub, members, defectColumns, contextFilter]); + }, [hub, windowedMembers, defectColumns, contextFilter]); // Plan C1 ships an empty top row for the dashboard. The full top-left "Cpk // vs target i-chart" slot requires a per-snapshot line-level Cp/Cpk series From 0864f9884780aea51a865652e4e49e6fcd80be5b Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Thu, 30 Apr 2026 05:17:48 +0300 Subject: [PATCH 13/20] =?UTF-8?q?feat(ui):=20TimelineWindowPicker=20?= =?UTF-8?q?=E2=80=94=20four-kind=20window=20selector=20with=20secondary=20?= =?UTF-8?q?inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: ruflo --- .../TimelineWindowPicker.tsx | 150 ++++++++++++++++++ .../__tests__/TimelineWindowPicker.test.tsx | 27 ++++ .../components/TimelineWindowPicker/index.ts | 1 + packages/ui/src/index.ts | 6 + 4 files changed, 184 insertions(+) create mode 100644 packages/ui/src/components/TimelineWindowPicker/TimelineWindowPicker.tsx create mode 100644 packages/ui/src/components/TimelineWindowPicker/__tests__/TimelineWindowPicker.test.tsx create mode 100644 packages/ui/src/components/TimelineWindowPicker/index.ts diff --git a/packages/ui/src/components/TimelineWindowPicker/TimelineWindowPicker.tsx b/packages/ui/src/components/TimelineWindowPicker/TimelineWindowPicker.tsx new file mode 100644 index 000000000..1e83675b5 --- /dev/null +++ b/packages/ui/src/components/TimelineWindowPicker/TimelineWindowPicker.tsx @@ -0,0 +1,150 @@ +/** + * TimelineWindowPicker — four-kind window selector with secondary inputs. + * + * Renders four pill-style chips (Fixed / Rolling / Open-ended / Cumulative) + * that switch the active TimelineWindow kind, plus a kind-specific + * secondary input (date range, days, or start date) when applicable. + * + * Uses semantic Tailwind classes per packages/ui/CLAUDE.md hard rule. + * Pill pattern mirrors DefectTypeSelector for cross-component consistency. + */ + +import type { TimelineWindow } from '@variscout/core'; + +export interface TimelineWindowPickerProps { + window: TimelineWindow; + onChange: (w: TimelineWindow) => void; + className?: string; +} + +const KINDS: TimelineWindow['kind'][] = ['fixed', 'rolling', 'openEnded', 'cumulative']; + +// TODO(i18n): route through @variscout/core/i18n in V1.5 (Task 16 sweep). +const LABELS: Record = { + fixed: 'Fixed', + rolling: 'Rolling', + openEnded: 'Open-ended', + cumulative: 'Cumulative', +}; + +const pillBase = + 'inline-flex items-center px-3 py-1 text-xs font-medium rounded-full border whitespace-nowrap transition-colors cursor-pointer'; +const pillActive = 'bg-blue-500 border-blue-500 text-white'; +const pillInactive = 'bg-surface-secondary border-edge text-content hover:bg-surface-tertiary'; + +const inputClass = + 'px-2 py-1 text-xs bg-surface-secondary border border-edge rounded text-content focus:outline-none focus:ring-2 focus:ring-blue-500/50'; + +/** + * Build a default TimelineWindow for a given kind. Lazy — `now` is captured + * at click-time so `openEnded.startISO` means "now-when-clicked", not + * module-load time. + */ +function defaultForKind(kind: TimelineWindow['kind']): TimelineWindow { + const now = new Date().toISOString(); + switch (kind) { + case 'fixed': + return { kind: 'fixed', startISO: '1970-01-01T00:00:00Z', endISO: now }; + case 'rolling': + return { kind: 'rolling', windowDays: 30 }; + case 'openEnded': + return { kind: 'openEnded', startISO: now }; + case 'cumulative': + return { kind: 'cumulative' }; + } +} + +export function TimelineWindowPicker({ window, onChange, className }: TimelineWindowPickerProps) { + const containerClass = ['flex flex-wrap gap-2 items-center', className].filter(Boolean).join(' '); + + return ( +
+ {KINDS.map(kind => { + const isActive = window.kind === kind; + return ( + + ); + })} + {window.kind === 'fixed' && } + {window.kind === 'rolling' && } + {window.kind === 'openEnded' && } +
+ ); +} + +// Compact secondary-input components — kept in this same file for V1. + +function FixedRangeInputs({ + window, + onChange, +}: { + window: Extract; + onChange: (w: TimelineWindow) => void; +}) { + return ( + <> + onChange({ ...window, startISO: new Date(e.target.value).toISOString() })} + /> + onChange({ ...window, endISO: new Date(e.target.value).toISOString() })} + /> + + ); +} + +function RollingDaysInput({ + window, + onChange, +}: { + window: Extract; + onChange: (w: TimelineWindow) => void; +}) { + return ( + onChange({ kind: 'rolling', windowDays: Math.max(1, Number(e.target.value)) })} + /> + ); +} + +function OpenEndedStartInput({ + window, + onChange, +}: { + window: Extract; + onChange: (w: TimelineWindow) => void; +}) { + return ( + + onChange({ kind: 'openEnded', startISO: new Date(e.target.value).toISOString() }) + } + /> + ); +} diff --git a/packages/ui/src/components/TimelineWindowPicker/__tests__/TimelineWindowPicker.test.tsx b/packages/ui/src/components/TimelineWindowPicker/__tests__/TimelineWindowPicker.test.tsx new file mode 100644 index 000000000..ec6da58a7 --- /dev/null +++ b/packages/ui/src/components/TimelineWindowPicker/__tests__/TimelineWindowPicker.test.tsx @@ -0,0 +1,27 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TimelineWindowPicker } from '../TimelineWindowPicker'; + +describe('TimelineWindowPicker', () => { + it('renders the four window-type chips', () => { + render( {}} />); + expect(screen.getByRole('button', { name: /fixed/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /rolling/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /open-ended/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /cumulative/i })).toBeInTheDocument(); + }); + + it('calls onChange when clicking a chip', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /rolling/i })); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ kind: 'rolling' })); + }); + + it('renders the days input when window.kind is rolling', () => { + render( + {}} /> + ); + expect(screen.getByDisplayValue('7')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/TimelineWindowPicker/index.ts b/packages/ui/src/components/TimelineWindowPicker/index.ts new file mode 100644 index 000000000..146765ce0 --- /dev/null +++ b/packages/ui/src/components/TimelineWindowPicker/index.ts @@ -0,0 +1 @@ +export { TimelineWindowPicker, type TimelineWindowPickerProps } from './TimelineWindowPicker'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2f303fa5a..0f4229d5a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -364,6 +364,12 @@ export { type DefectTypeSelectorProps, } from './components/EvidenceMap/DefectTypeSelector'; +// Timeline Window Picker (four-kind window selector for multi-level SCOUT) +export { + TimelineWindowPicker, + type TimelineWindowPickerProps, +} from './components/TimelineWindowPicker'; + // Evidence Map Insufficient Data State (empty state when defect type lacks data) export { InsufficientDataState, From 607f5d28392f369325492b93ddbff31953b37ec6 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Thu, 30 Apr 2026 05:23:13 +0300 Subject: [PATCH 14/20] feat(ui): DashboardLayoutBase exposes TimelineWindowPicker in dashboard chrome Co-Authored-By: ruflo --- .../DashboardBase/DashboardLayoutBase.tsx | 24 +++++++++++++++++- .../__tests__/DashboardLayoutBase.test.tsx | 25 ++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/DashboardBase/DashboardLayoutBase.tsx b/packages/ui/src/components/DashboardBase/DashboardLayoutBase.tsx index a611585df..761509dcc 100644 --- a/packages/ui/src/components/DashboardBase/DashboardLayoutBase.tsx +++ b/packages/ui/src/components/DashboardBase/DashboardLayoutBase.tsx @@ -7,12 +7,13 @@ import { FilterContextBar } from '../FilterContextBar'; import { BoxplotDisplayToggle } from '../BoxplotDisplayToggle'; import { ChartInsightChip } from '../ChartInsightChip'; import { AnnotationContextMenu } from '../AnnotationContextMenu'; +import { TimelineWindowPicker } from '../TimelineWindowPicker'; import DashboardChartCard from './DashboardChartCard'; import DashboardGrid from './DashboardGrid'; import type { HighlightColor } from '../ChartAnnotationLayer/types'; import type { UseChartInsightsReturn, ChartTitles } from '@variscout/hooks'; import type { FilterChipData } from '../filterTypes'; -import type { GlossaryTerm } from '@variscout/core'; +import type { GlossaryTerm, TimelineWindow } from '@variscout/core'; // ---------- Annotations shape ---------- export interface DashboardAnnotations { @@ -188,6 +189,16 @@ export interface DashboardLayoutBaseProps { paretoObservationCount?: number; /** Dashboard layout mode: 'grid' (viewport-fit) or 'scroll' (stacked) */ layout?: 'grid' | 'scroll'; + + // ---- Timeline window (Multi-level SCOUT V1) ---- + /** + * Optional dashboard-level TimelineWindow control. Renders the + * TimelineWindowPicker adjacent to the outcome selector when both + * `timelineWindow` and `onTimelineWindowChange` are provided. + * Hosts that don't pass these props are unchanged (no picker rendered). + */ + timelineWindow?: TimelineWindow; + onTimelineWindowChange?: (w: TimelineWindow) => void; } /** @@ -266,6 +277,8 @@ const DashboardLayoutBase: React.FC = ({ boxplotObservationCount, paretoObservationCount, layout, + timelineWindow, + onTimelineWindowChange, }) => { const { formatStat } = useTranslation(); const { @@ -351,6 +364,15 @@ const DashboardLayoutBase: React.FC = ({ )} {ichartHeaderExtra} + + {timelineWindow && onTimelineWindowChange && ( +
+ +
+ )} {ichartExtraControls} diff --git a/packages/ui/src/components/DashboardBase/__tests__/DashboardLayoutBase.test.tsx b/packages/ui/src/components/DashboardBase/__tests__/DashboardLayoutBase.test.tsx index 274eb7a4a..89577aa44 100644 --- a/packages/ui/src/components/DashboardBase/__tests__/DashboardLayoutBase.test.tsx +++ b/packages/ui/src/components/DashboardBase/__tests__/DashboardLayoutBase.test.tsx @@ -6,9 +6,10 @@ */ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import DashboardLayoutBase from '../DashboardLayoutBase'; import type { DashboardLayoutBaseProps } from '../DashboardLayoutBase'; +import type { TimelineWindow } from '@variscout/core'; const noopAsync = vi.fn().mockResolvedValue(undefined); const noop = vi.fn(); @@ -197,6 +198,28 @@ describe('DashboardLayoutBase', () => { expect(screen.getByTestId('custom-title')).toBeDefined(); }); + it('renders TimelineWindowPicker when window + change handler are provided, and propagates kind changes', () => { + const onTimelineWindowChange = vi.fn(); + const window: TimelineWindow = { kind: 'cumulative' }; + render( + + ); + expect(screen.getByTestId('timeline-window-picker-host')).toBeDefined(); + // Click "Rolling" chip — switches kind, fires onChange with rolling default. + fireEvent.click(screen.getByTestId('timeline-window-chip-rolling')); + expect(onTimelineWindowChange).toHaveBeenCalledTimes(1); + expect(onTimelineWindowChange.mock.calls[0][0]).toMatchObject({ kind: 'rolling' }); + }); + + it('does not render TimelineWindowPicker when timelineWindow prop is omitted', () => { + render(); + expect(screen.queryByTestId('timeline-window-picker-host')).toBeNull(); + }); + it('uses a neutral variation-sources title when no subgroup factor is selected', () => { render( From 4e7443be219444db119e7265cc86b6c5011c94fe Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Thu, 30 Apr 2026 05:28:55 +0300 Subject: [PATCH 15/20] feat(ui): FindingCard renders window-context footer when present Co-Authored-By: ruflo --- .../components/FindingsLog/FindingCard.tsx | 51 ++++++++++++++- .../__tests__/FindingCard.test.tsx | 65 +++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/FindingsLog/__tests__/FindingCard.test.tsx diff --git a/packages/ui/src/components/FindingsLog/FindingCard.tsx b/packages/ui/src/components/FindingsLog/FindingCard.tsx index b46df0625..d833b4c89 100644 --- a/packages/ui/src/components/FindingsLog/FindingCard.tsx +++ b/packages/ui/src/components/FindingsLog/FindingCard.tsx @@ -16,8 +16,15 @@ import type { FindingSource, FindingStatus, FindingTag, + TimelineWindow, } from '@variscout/core'; -import { getFindingStatus } from '@variscout/core'; +import { + getFindingStatus, + isFixedWindow, + isOpenEndedWindow, + isRollingWindow, +} from '@variscout/core'; +import { formatDate } from '@variscout/core/i18n'; import { useTranslation } from '@variscout/hooks'; import FindingEditor from './FindingEditor'; import FindingStatusBadge from './FindingStatusBadge'; @@ -117,6 +124,27 @@ export interface FindingCardProps { voiceInput?: VoiceInputConfig; } +/** + * Format a TimelineWindow for compact display in the window-context footer. + * Locale-aware via formatDate from @variscout/core/i18n. + */ +function formatWindow(window: TimelineWindow, locale: Parameters[1]): string { + if (isFixedWindow(window)) { + return `${formatDate(new Date(window.startISO), locale, 'short')} – ${formatDate( + new Date(window.endISO), + locale, + 'short' + )}`; + } + if (isRollingWindow(window)) { + return `Rolling ${window.windowDays}d`; + } + if (isOpenEndedWindow(window)) { + return `Since ${formatDate(new Date(window.startISO), locale, 'short')}`; + } + return 'Cumulative'; +} + /** * Individual finding card showing filter chips, stats, and analyst note. * Click the card body to restore its filter state. @@ -156,7 +184,7 @@ const FindingCard: React.FC = ({ renderActionAssigneePicker, voiceInput, }) => { - const { t, formatStat } = useTranslation(); + const { t, formatStat, locale } = useTranslation(); const [isEditing, setIsEditing] = useState(false); const { context } = finding; const status = getFindingStatus(finding); @@ -458,6 +486,25 @@ const FindingCard: React.FC = ({ voiceInput={voiceInput} /> )} + + {/* Window-context footer (multi-level SCOUT V1 — drift snapshot) */} + {finding.windowContext && ( +
+ Captured: {formatWindow(finding.windowContext.windowAtCreation, locale)} + {finding.windowContext.statsAtCreation.cpk !== undefined && ( + + Cpk @ creation{' '} + + {formatStat(finding.windowContext.statsAtCreation.cpk, 2)} + + + )} + n={finding.windowContext.statsAtCreation.n} +
+ )} ); diff --git a/packages/ui/src/components/FindingsLog/__tests__/FindingCard.test.tsx b/packages/ui/src/components/FindingsLog/__tests__/FindingCard.test.tsx new file mode 100644 index 000000000..cb628d5d7 --- /dev/null +++ b/packages/ui/src/components/FindingsLog/__tests__/FindingCard.test.tsx @@ -0,0 +1,65 @@ +/** + * Tests for the FindingCard window-context footer (multi-level SCOUT V1). + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import FindingCard from '../FindingCard'; +import type { Finding } from '@variscout/core'; + +function makeFinding(overrides: Partial = {}): Finding { + return { + id: 'f-window-1', + text: 'Drift suspected on Line 2', + createdAt: Date.now(), + context: { + activeFilters: { Machine: ['B'] }, + cumulativeScope: 30, + stats: { mean: 10, samples: 50 }, + }, + status: 'observed', + comments: [], + statusChangedAt: Date.now(), + ...overrides, + }; +} + +const noopHandlers = { + onEdit: vi.fn(), + onDelete: vi.fn(), + onRestore: vi.fn(), +}; + +describe('FindingCard window-context footer', () => { + it('renders window + stats snapshot when windowContext is present', () => { + const finding = makeFinding({ + windowContext: { + windowAtCreation: { + kind: 'fixed', + startISO: '2026-04-01T00:00:00.000Z', + endISO: '2026-04-15T00:00:00.000Z', + }, + statsAtCreation: { cpk: 0.62, mean: 10.1, sigma: 0.3, n: 200 }, + }, + }); + + render(); + + const footer = screen.getByTestId('finding-window-footer'); + expect(footer).toBeDefined(); + // Captured-window line uses the fixed-window date range + expect(footer.textContent).toMatch(/Captured:/); + // Cpk @ creation rendered via formatStat (locale-aware), not toFixed + expect(footer.textContent).toMatch(/Cpk @ creation/); + expect(footer.textContent).toMatch(/0\.62/); + // Sample count + expect(footer.textContent).toMatch(/n=200/); + }); + + it('does not render footer when windowContext is absent', () => { + const finding = makeFinding({ windowContext: undefined }); + + render(); + + expect(screen.queryByTestId('finding-window-footer')).toBeNull(); + }); +}); From 49e088e5217ae1e93a604562031ecc7a565afaa7 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Thu, 30 Apr 2026 05:36:09 +0300 Subject: [PATCH 16/20] refactor(azure): ProcessHubCapabilityTab consults useDataRouter, threads window Co-Authored-By: ruflo --- .../components/ProcessHubCapabilityTab.tsx | 55 +++++++++++++++- .../plans/2026-04-29-multi-level-scout-v1.md | 66 +++---------------- 2 files changed, 60 insertions(+), 61 deletions(-) diff --git a/apps/azure/src/components/ProcessHubCapabilityTab.tsx b/apps/azure/src/components/ProcessHubCapabilityTab.tsx index d910a737c..6420c1f1b 100644 --- a/apps/azure/src/components/ProcessHubCapabilityTab.tsx +++ b/apps/azure/src/components/ProcessHubCapabilityTab.tsx @@ -7,11 +7,24 @@ * * Migration banner + modal are wired in T11 at hub-level (separate file). * - * See spec docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md. + * Multi-level SCOUT V1 (Task 13): the call site now consults `useDataRouter` + * (via the standard mode strategy) as a sanity-check on the dataflow choice + * and threads a `TimelineWindow` into `useProductionLineGlanceData`. The + * dashboard composition itself stays hardcoded — slot-component-registry is + * a V2/V3 concern and no such registry exists in V1. + * + * See spec docs/superpowers/specs/2026-04-28-production-line-glance-surface-wiring-design.md + * and docs/superpowers/specs/2026-04-29-multi-level-scout-design.md. */ -import React from 'react'; +import React, { useState } from 'react'; import { ProductionLineGlanceDashboard, ProductionLineGlanceFilterStrip } from '@variscout/ui'; -import { useProductionLineGlanceData, useProductionLineGlanceFilter } from '@variscout/hooks'; +import { + useDataRouter, + useProductionLineGlanceData, + useProductionLineGlanceFilter, +} from '@variscout/hooks'; +import { detectScope } from '@variscout/core'; +import type { TimelineWindow } from '@variscout/core'; import { useHubProvision } from '../features/processHub'; import type { ProcessHubInvestigation, ProcessHubRollup } from '@variscout/core'; @@ -19,14 +32,50 @@ export interface ProcessHubCapabilityTabProps { rollup: ProcessHubRollup; } +const DEFAULT_WINDOW: TimelineWindow = { kind: 'cumulative' }; + export const ProcessHubCapabilityTab: React.FC = ({ rollup }) => { const provision = useHubProvision({ rollup }); const filter = useProductionLineGlanceFilter(); + + // Hub-level window state. Hub doesn't carry its own TimelineWindow envelope + // in V1 — `useTimelineWindow` is keyed on a single ProcessHubInvestigation, + // and the hub view aggregates many. Local useState is the correct V1 fit; + // hub-level persistence is Task 14's problem. + // TODO(multi-level-scout V2/Task 14): wire hub-level window persistence. + const [window] = useState(DEFAULT_WINDOW); + + // Scope detection: hub views are typically `b1` (multi-step, multi-member). + // If a single representative member exists, use detectScope on it; otherwise + // default to `b1` (the hub-aggregate scope). + const scope = provision.members.length === 1 ? detectScope(provision.members[0]) : 'b1'; + + // Sanity-check the dataflow choice via the strategy router. The actual hook + // used (`useProductionLineGlanceData`) must remain known at compile time — + // React forbids conditional hook calls. The router is a validation hook + a + // V2/V3 extension point. + const router = useDataRouter({ + mode: 'standard', + modeOptions: { standardIChartMetric: 'capability' }, + scope, + phase: 'hub', + window, + context: filter.value, + }); + if (import.meta.env.DEV && router.hook !== 'useProductionLineGlanceData') { + console.warn( + `[ProcessHubCapabilityTab] dataRouter expected 'useProductionLineGlanceData', got '${router.hook}'` + ); + } + const data = useProductionLineGlanceData({ hub: provision.hub, members: provision.members, rowsByInvestigation: provision.rowsByInvestigation, contextFilter: filter.value, + window, + // timeColumnByInvestigation is not reachable at hub level in V1; the hook + // skips windowing for any member without a time column (safer fail-mode). }); return ( diff --git a/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md b/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md index 5628f4d0e..b345cfc8f 100644 --- a/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md +++ b/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md @@ -1791,70 +1791,20 @@ git commit -m "feat(ui): Finding card renders window-context footer when present --- -## Task 13: Refactor ProductionLineGlanceDashboard onto strategy + dataRouter +## Task 13: Route ProcessHubCapabilityTab via useDataRouter, thread window into useProductionLineGlanceData -**Files:** - -- Modify: `packages/ui/src/components/ProductionLineGlance/ProductionLineGlanceDashboard.tsx` (or wherever it lives; verify path) -- Modify: `apps/azure/src/components/ProcessHubCapabilityTab.tsx` +> **Revised 2026-04-30** (V1 interpretation). The original spec's `` code can't execute — `ChartSlots` carries `ChartSlotType` strings (e.g. `'capability-ichart'`), not React components. There is no slot-type-to-component registry in V1. ProductionLineGlanceDashboard keeps its hardcoded 4-chart composition (those charts are specific to the capability/hub phase). The "route via strategy" happens at the call site: `ProcessHubCapabilityTab` now consults `useDataRouter` (as a dev-mode sanity check on the dataflow choice — the hook used must remain known at compile time, since React forbids conditional hook calls) and threads a `TimelineWindow` into `useProductionLineGlanceData`. Slot-component registry is a V2/V3 concern. -- [ ] **Step 13.1: Inspect current shape** +**Files actually modified:** -Read the two files to understand: +- `apps/azure/src/components/ProcessHubCapabilityTab.tsx` — adds `useDataRouter` sanity check; computes `scope` via `detectScope` (single member) or defaults to `b1` (hub aggregate); passes `window` to `useProductionLineGlanceData`. Window state is local `useState({ kind: 'cumulative' })` with a TODO referencing Task 14 (hub-level persistence is genuinely Task 14's problem; the hub envelope doesn't carry its own `TimelineWindow` field, and `useTimelineWindow` is keyed on a single investigation). -- How `ProductionLineGlanceDashboard` receives data today (probably directly from `useProductionLineGlanceData`). -- How `ProcessHubCapabilityTab` mounts it. +**Not modified:** -- [ ] **Step 13.2: Refactor dashboard to consume strategy slots** +- `packages/ui/src/components/ProductionLineGlanceDashboard/ProductionLineGlanceDashboard.tsx` — purely presentational, props-driven; no refactor needed. +- `packages/core/src/analysisStrategy.ts` — `dataRouter` already exposed by Task 7. -Replace direct chart rendering with strategy-resolved slots. The dashboard becomes: - -```tsx -import { getStrategy, resolveMode } from '@variscout/core'; -import { useDataRouter } from '@variscout/hooks'; - -export function ProductionLineGlanceDashboard({ - hub, - members, - rowsByInvestigation, - contextFilter, - window, -}: Props) { - const strategy = getStrategy(resolveMode('standard', { standardIChartMetric: 'capability' })); - const router = useDataRouter({ - mode: 'standard', - modeContext: { standardIChartMetric: 'capability' }, - scope: 'b1', // hub aggregates B1 investigations - phase: 'hub', - window, - context: contextFilter, - }); - - // The 4 slots from strategy.chartSlots: - return ( - - - - - - - ); -} -``` - -(The exact prop wiring depends on what each ChartSlotType expects — preserve current behaviour, just route via strategy instead of hardcoded composition.) - -- [ ] **Step 13.3: Run app tests + commit** - -```bash -pnpm --filter @variscout/azure-app test -pnpm --filter @variscout/ui test -``` - -```bash -git add packages/ui/src/components/ProductionLineGlance/ apps/azure/src/components/ProcessHubCapabilityTab.tsx -git commit -m "refactor(ui+azure): ProductionLineGlanceDashboard uses strategy + dataRouter" -``` +**Verification:** `pnpm --filter @variscout/azure-app test` (970/970), `pnpm --filter @variscout/azure-app build` clean, `pnpm --filter @variscout/ui test` (1285/1285). --- From af11e6d01b4ee0d3fe8a9e87b44c0368295d4a17 Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Thu, 30 Apr 2026 05:55:43 +0300 Subject: [PATCH 17/20] feat(apps): wire timeline window into Dashboards + Hub Capability picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan Task 14 V1 interpretation. Original plan used the old useTimelineWindow({ investigationId, defaultKind }) signature that Task 8's revision (commit cf5daaa6) already replaced with a pure projection over an investigation envelope. The investigation Dashboards (Azure + PWA) don't receive a ProcessHubInvestigation envelope today — they read from useProjectStore — so the persistence-aware hook can't be used there in V1. Local useState is the correct V1 fit, with a documented V2 follow-up to make the dashboards investigation-aware. Hub Capability tab already had local window state from Task 13; this commit extends the destructure to expose the setter and renders TimelineWindowPicker above the dashboard composition. Cadence-default override (rolling matched to hub.cadence on first mount) is deferred to V1.5 per the plan revision. Plan-file Task 14 body replaced with a V1-interpretation note. Co-Authored-By: ruflo --- apps/azure/src/components/Dashboard.tsx | 11 ++- .../components/ProcessHubCapabilityTab.tsx | 18 +++-- apps/pwa/src/components/Dashboard.tsx | 17 +++- .../plans/2026-04-29-multi-level-scout-v1.md | 79 +++++-------------- 4 files changed, 58 insertions(+), 67 deletions(-) diff --git a/apps/azure/src/components/Dashboard.tsx b/apps/azure/src/components/Dashboard.tsx index 7c2b1d0af..ec2d71a70 100644 --- a/apps/azure/src/components/Dashboard.tsx +++ b/apps/azure/src/components/Dashboard.tsx @@ -53,7 +53,7 @@ import { useJourneyPhase, useCapabilityIChartData, } from '@variscout/hooks'; -import type { AIContext } from '@variscout/core'; +import type { AIContext, TimelineWindow } from '@variscout/core'; import type { ViewState } from '@variscout/hooks'; import { Activity, BarChart3, Gauge, Timer, ArrowLeft, Settings2 } from 'lucide-react'; @@ -198,7 +198,12 @@ const Dashboard = ({ const selectedPoints = useProjectStore(s => s.selectedPoints); const clearSelection = useProjectStore(s => s.clearSelection); const defectMapping = useProjectStore(s => s.defectMapping); - const { filteredData } = useFilteredData(); + // Multi-level SCOUT V1: local timeline-window state. Investigation-level + // persistence — V2 wires through useTimelineWindow when the dashboards + // become investigation-aware (Dashboard currently reads useProjectStore + // and does not receive a ProcessHubInvestigation envelope). + const [timelineWindow, setTimelineWindow] = useState({ kind: 'cumulative' }); + const { filteredData } = useFilteredData({ window: timelineWindow }); const { stats, isComputing } = useAnalysisStats(); const { stagedStats } = useStagedAnalysis(); const { getTerm } = useGlossary(); @@ -705,6 +710,8 @@ const Dashboard = ({ ) : (
= ( // Hub-level window state. Hub doesn't carry its own TimelineWindow envelope // in V1 — `useTimelineWindow` is keyed on a single ProcessHubInvestigation, - // and the hub view aggregates many. Local useState is the correct V1 fit; - // hub-level persistence is Task 14's problem. - // TODO(multi-level-scout V2/Task 14): wire hub-level window persistence. - const [window] = useState(DEFAULT_WINDOW); + // and the hub view aggregates many. Local useState is the correct V1 fit. + // TODO(multi-level-scout V1.5): default the window to rolling matched to + // hub.cadence on first mount. + // TODO(multi-level-scout V2): wire hub-level window persistence. + const [window, setWindow] = useState(DEFAULT_WINDOW); // Scope detection: hub views are typically `b1` (multi-step, multi-member). // If a single representative member exists, use detectScope on it; otherwise @@ -86,6 +91,9 @@ export const ProcessHubCapabilityTab: React.FC = ( value={filter.value} onChange={filter.onChange} /> +
+ +
s.clearSelection); const analysisMode = useProjectStore(s => s.analysisMode); const defectMapping = useProjectStore(s => s.defectMapping); - const { filteredData } = useFilteredData(); + // Multi-level SCOUT V1: local timeline-window state. Investigation-level + // persistence — V2 wires through useTimelineWindow when the dashboards + // become investigation-aware (Dashboard currently reads useProjectStore + // and does not receive a ProcessHubInvestigation envelope). + const [timelineWindow, setTimelineWindow] = useState({ kind: 'cumulative' }); + const { filteredData } = useFilteredData({ window: timelineWindow }); const { stats, isComputing } = useAnalysisStats(); const { stagedStats } = useStagedAnalysis(); const [analysisLensTab, setAnalysisLensTab] = useState('probability'); @@ -666,6 +677,8 @@ const Dashboard = ({ {/* Dashboard View */} ; -``` - -- [ ] **Step 14.2: Wire hub-time window into Hub Capability tab** - -```tsx -const cadence = hub.cadence ?? 'weekly'; -const cadenceDays: Record = { hourly: 1, daily: 1, weekly: 7, monthly: 30 }; - -const { window, setWindow } = useTimelineWindow({ - investigationId: `hub-${hub.id}`, - defaultKind: 'rolling', -}); -// First-mount default override based on cadence: -useEffect(() => { - if (window.kind === 'cumulative') { - setWindow({ kind: 'rolling', windowDays: cadenceDays[cadence] ?? 7 }); - } -}, []); // first mount only -``` - -- [ ] **Step 14.3: Run end-to-end tests + commit** - -```bash -pnpm test -``` - -Expected: all tests green across packages. - -```bash -git add apps/ -git commit -m "feat(apps): wire useTimelineWindow into Dashboard + Hub Capability tab" -``` +## Task 14: Wire timeline window into Dashboards + Hub Capability picker + +> **Revised 2026-04-30** (V1 interpretation). The original task body called `useTimelineWindow({ investigationId, defaultKind })` — that signature was rejected in commit `cf5daaa6` (Task 8 revision earlier in this same plan file). The current `useTimelineWindow` signature is `({ investigation, onChange })` — a pure projection over an investigation envelope, with `onChange` wired by the caller to `persistInvestigation`. It cannot be used in the dashboards as written. +> +> Reality on the ground: +> +> - `apps/azure/src/components/Dashboard.tsx` and `apps/pwa/src/components/Dashboard.tsx` do not receive a `ProcessHubInvestigation` envelope — they read state from `useProjectStore` (rawData/filters/outcome). With no investigation reachable, `useTimelineWindow` is the wrong shape; local `useState({ kind: 'cumulative' })` is the correct V1 fit. A TODO references investigation-level persistence as the V2 path (when the dashboards become investigation-aware). +> - `ProcessHubCapabilityTab` already held local `useState` from Task 13 but did not render a picker. Task 14 surfaces the picker above the dashboard (below the existing `ProductionLineGlanceFilterStrip`). +> - The `useEffect` cadence-default (Step 14.2) is **deferred to V1.5** — it is a UX nicety, not a structural requirement, and reading `hub.cadence` introduces dependencies whose contract isn't worth verifying for V1. A TODO captures it. +> +> **Files modified in this task:** +> +> - `apps/azure/src/components/Dashboard.tsx` — local `timelineWindow` state, threaded into `DashboardLayoutBase` (existing `timelineWindow`/`onTimelineWindowChange` props from Task 11) and into `useFilteredData({ window })` (Task 9). +> - `apps/pwa/src/components/Dashboard.tsx` — same wiring as Azure. +> - `apps/azure/src/components/ProcessHubCapabilityTab.tsx` — extends the existing `useState(DEFAULT_WINDOW)` to expose the setter and renders `` (exported from `@variscout/ui` per Task 10) above `ProductionLineGlanceDashboard`. +> +> **Not modified (deliberately):** +> +> - `useTimelineWindow.ts`, `DashboardLayoutBase.tsx`, `TimelineWindowPicker.tsx`, `ProductionLineGlanceDashboard.tsx`, `@variscout/core` types — earlier tasks already exposed the props and contracts; Task 14 only plugs them in. +> +> **Verification:** `pnpm --filter @variscout/azure-app test` (970/970), `pnpm --filter @variscout/pwa test` (124/124), `pnpm --filter @variscout/azure-app build` clean. --- From 6c682187872a961e2281a662ae27fc4d587810eb Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Thu, 30 Apr 2026 06:01:51 +0300 Subject: [PATCH 18/20] chore: ADR-074 boundary check script + pre-commit wiring Co-Authored-By: ruflo --- .husky/pre-commit | 3 + .../plans/2026-04-29-multi-level-scout-v1.md | 6 +- package.json | 1 + scripts/check-level-boundaries.sh | 55 +++++++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) create mode 100755 scripts/check-level-boundaries.sh diff --git a/.husky/pre-commit b/.husky/pre-commit index 80c11f47d..2dccc5460 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -7,6 +7,9 @@ pnpm docs:check # SSOT check — warn if CLAUDE.md sections duplicate content across files bash scripts/check-ssot.sh +# ADR-074 level-spanning-surface boundary check +bash scripts/check-level-boundaries.sh + # CLAUDE.md size check — fail if >120% of per-file budget bash scripts/check-claude-md-size.sh diff --git a/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md b/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md index 3683bf965..65dc5267c 100644 --- a/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md +++ b/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md @@ -1840,7 +1840,7 @@ git commit -m "feat(ui): Finding card renders window-context footer when present - Modify: `package.json` (add npm script) - Modify: `.husky/pre-commit` (or `lint-staged` config) — add the check -- [ ] **Step 15.1: Write the script** +- [x] **Step 15.1: Write the script** ```bash #!/usr/bin/env bash @@ -1884,7 +1884,7 @@ echo "✓ ADR-074 boundaries clean" chmod +x scripts/check-level-boundaries.sh ``` -- [ ] **Step 15.2: Add npm script + pre-commit hook** +- [x] **Step 15.2: Add npm script + pre-commit hook** In `package.json` `scripts`: @@ -1898,7 +1898,7 @@ In `.husky/pre-commit` (or add to `lint-staged.config.js`): bash scripts/check-level-boundaries.sh ``` -- [ ] **Step 15.3: Run + commit** +- [x] **Step 15.3: Run + commit** ```bash bash scripts/check-level-boundaries.sh diff --git a/package.json b/package.json index bd91c6e64..e9c8d732f 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "codex:ruflo-check": "bash scripts/check-codex-ruflo.sh", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", + "check:boundaries": "bash scripts/check-level-boundaries.sh", "check:i18n": "npx tsx scripts/check-i18n.ts" }, "lint-staged": { diff --git a/scripts/check-level-boundaries.sh b/scripts/check-level-boundaries.sh new file mode 100755 index 000000000..04515a5ba --- /dev/null +++ b/scripts/check-level-boundaries.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# check-level-boundaries.sh — enforce ADR-074 boundary policy for the +# multi-level SCOUT design family. Verifies that level-spanning surfaces +# do not reimplement each other's primary views. +# +# Each check pairs a forbidden symbol pattern with the directory in which +# that symbol must NOT appear. If the directory is absent, the boundary is +# preserved by structural absence and the check is logged but does not fail. + +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" || exit 2 + +FAILED=0 + +check() { + local pattern="$1" + local target="$2" + local message="$3" + if [ -d "$target" ]; then + # grep -rE: portable POSIX-ish recursive ERE search. -q exits 0 on match. + if grep -rEq "$pattern" "$target" 2>/dev/null; then + echo " ✗ $message" >&2 + echo " pattern: $pattern" >&2 + echo " in: $target" >&2 + FAILED=$((FAILED + 1)) + else + echo " ✓ $message" + fi + else + echo " · $target not yet present (boundary preserved by structural absence)" + fi +} + +echo "=== ADR-074 boundary checks ===" +check "outcomeStats|outcomeBoxplot|outcomeIChart" \ + "packages/ui/src/components/InvestigationWall" \ + "Investigation Wall does not reimplement L1 chart rendering" +check "stratifyByFactor|factorEdge|factorRelationship" \ + "packages/ui/src/components/DashboardBase" \ + "SCOUT does not reimplement Evidence Map's factor-network rendering" +check "hypothesisCanvas|suspectedCauseHub|gateNode" \ + "packages/ui/src/components/Frame" \ + "FRAME does not embed hypothesis canvas surfaces" +check "LayeredProcessView|OperationsBand" \ + "packages/ui/src/components/EvidenceMap" \ + "Evidence Map does not reimplement L2 flow rendering" + +if [ "$FAILED" -gt 0 ]; then + echo "" >&2 + echo "✗ ADR-074 boundary violations: $FAILED" >&2 + echo "See docs/07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md" >&2 + exit 1 +fi +echo "✓ ADR-074 boundaries clean" From 5566d14b880f0758598080c13b150febb7cf696f Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Thu, 30 Apr 2026 06:44:25 +0200 Subject: [PATCH 19/20] docs: V1 documentation completeness sweep + lifecycle updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Task 16 (Steps 16.1–16.6, 16.8) of the Multi-level SCOUT V1 plan. Created (3): - docs/03-features/analysis/timeline-window-investigations.md - docs/03-features/analysis/multi-level-dashboard.md - docs/05-technical/architecture/timeline-window-architecture.md Modified (16): - Vision: methodology.md temporal-scope paragraph; eda-mental-model.md SCOUT-loops note - Glossary: docs glossary + packages/core/src/glossary/terms.ts (timeline window, output rate, bottleneck, finding drift, hub-time, investigation-time) - Journeys: USER-JOURNEYS.md + USER-JOURNEYS-CAPABILITY.md timeline-picker mentions - Agent map: docs/llms.txt new entry points - Per-package CLAUDE.md: core (timeline/throughput/findings), hooks (useTimelineWindow + useDataRouter), ui (TimelineWindowPicker), apps/azure + apps/pwa (multi-level integration) - Lifecycle: spec status draft → delivered, last-reviewed 2026-04-30; decision-log V1 row + SCOUT journey row updated; ADR-074 strikes the 'to be added' note (boundary script ships in 6c682187) - Plan-file checkboxes for Steps 16.1–16.6 + 16.8 ticked; 16.7/16.9 left for orchestrator Step 16.7 (memory updates) handled separately by orchestrator. Step 16.9 (push + open PR) deferred to user-authorized step. Co-Authored-By: ruflo --- apps/azure/CLAUDE.md | 1 + apps/pwa/CLAUDE.md | 2 +- docs/01-vision/eda-mental-model.md | 2 + docs/01-vision/methodology.md | 2 + .../analysis/multi-level-dashboard.md | 77 ++++++++ .../timeline-window-investigations.md | 75 ++++++++ docs/03-features/learning/glossary.md | 13 ++ .../timeline-window-architecture.md | 168 ++++++++++++++++++ ...-level-spanning-surface-boundary-policy.md | 2 +- docs/USER-JOURNEYS-CAPABILITY.md | 4 + docs/USER-JOURNEYS.md | 5 +- docs/decision-log.md | 48 ++--- docs/llms.txt | 3 + .../plans/2026-04-29-multi-level-scout-v1.md | 14 +- .../2026-04-29-multi-level-scout-design.md | 3 +- packages/core/CLAUDE.md | 2 +- packages/core/src/glossary/terms.ts | 62 +++++++ packages/hooks/CLAUDE.md | 1 + packages/ui/CLAUDE.md | 1 + 19 files changed, 449 insertions(+), 36 deletions(-) create mode 100644 docs/03-features/analysis/multi-level-dashboard.md create mode 100644 docs/03-features/analysis/timeline-window-investigations.md create mode 100644 docs/05-technical/architecture/timeline-window-architecture.md diff --git a/apps/azure/CLAUDE.md b/apps/azure/CLAUDE.md index b7dba824b..dc8fbb9d0 100644 --- a/apps/azure/CLAUDE.md +++ b/apps/azure/CLAUDE.md @@ -16,6 +16,7 @@ Azure Team App — Feature-Sliced Design with Zustand feature stores, IndexedDB - App Insights wired at `src/lib/appInsights.ts`. `services/storage.ts` is the facade for both local + cloud. - Domain stores from `@variscout/stores` are the source of truth for project/investigation/improvement/session data. Feature stores hold UI-only state. - File Picker: local files only (`components/FileBrowseButton.tsx`). SharePoint picker removed per ADR-059. +- Multi-level surfaces: SCOUT dashboard (investigation-time picker) and Hub Capability tab (hub-time, rolling default matched to cadence) link as peers; ADR-074 boundary policy applies. ## Test command diff --git a/apps/pwa/CLAUDE.md b/apps/pwa/CLAUDE.md index efe438949..39959abf0 100644 --- a/apps/pwa/CLAUDE.md +++ b/apps/pwa/CLAUDE.md @@ -12,7 +12,7 @@ Free PWA. Session-only (no persistence), Context-based state, education tier. - State via React Context (`DataContext`). No Zustand stores in PWA. - Embedded mode supported for iframes (see flows in `docs/02-journeys/flows/pwa-education.md`). -- Entry: `src/components/Dashboard.tsx`. +- Entry: `src/components/Dashboard.tsx`. Hosts the timeline-window picker in the dashboard chrome (investigation-time, default `open-ended`); session-local in V1. ## Test command diff --git a/docs/01-vision/eda-mental-model.md b/docs/01-vision/eda-mental-model.md index 2f601bb35..c9a9eb3db 100644 --- a/docs/01-vision/eda-mental-model.md +++ b/docs/01-vision/eda-mental-model.md @@ -68,6 +68,8 @@ Step 1 produces the first version of the analysis plan: the issue statement, the Steps 2-4 are iterative. The analyst does not pass through them once — they cycle through them repeatedly, each iteration producing a more refined understanding. This is the EDA inner loop, and it is the heart of the methodology. +In VariScout's multi-level SCOUT, each loop carries an explicit **timeline window** — fixed, rolling, open-ended, or cumulative — so the temporal scope of every iteration is preserved alongside its filters and findings. See [Timeline Windows in Investigations](../03-features/analysis/timeline-window-investigations.md). + For full thesis excerpts and figure descriptions, see [Turtiainen (2019) Reference](references/turtiainen-2019-eda-mental-model.md). --- diff --git a/docs/01-vision/methodology.md b/docs/01-vision/methodology.md index 164262193..ddde58150 100644 --- a/docs/01-vision/methodology.md +++ b/docs/01-vision/methodology.md @@ -210,6 +210,8 @@ A critical distinction underpins the investigation flow: - **Issue Statement** (the input): A vague concern that initiates the investigation. Example: _"Fill weight on line 3 seems too variable."_ Watson (2019a) defines an issue as a concern arising from a gap between customer expectation and observation. - **Problem Statement** (the output): A precise declaration answering Watson's three questions: (1) What measure needs to change? (2) How should it change? (3) What is the scope? Example: _"Reduce fill weight variation on line 3, night shift, heads 5-8, from Cpk 0.62 to target Cpk 1.33."_ +**Temporal scope is part of the third question.** The "when does this happen?" dimension is captured by a **timeline window** on the investigation — a tagged variant of fixed, rolling, open-ended, or cumulative — that travels with every chart, every Finding, and every drift comparison. Without an explicit window, "scope" stays vague; with one, the analysis can answer "this happens during these dates, on this rolling horizon, since this start" precisely. See [Timeline Windows in Investigations](../03-features/analysis/timeline-window-investigations.md). + The gap between the two is the entire EDA journey. Every question asked and answered in VariScout exists to close this gap. #### Question Generation diff --git a/docs/03-features/analysis/multi-level-dashboard.md b/docs/03-features/analysis/multi-level-dashboard.md new file mode 100644 index 000000000..2b2e66548 --- /dev/null +++ b/docs/03-features/analysis/multi-level-dashboard.md @@ -0,0 +1,77 @@ +--- +title: Multi-Level Dashboard +audience: [analyst] +category: analysis +status: delivered +related: [timeline-window-investigations, capability, process-hub-capability, stats-panel] +--- + +# Multi-Level Dashboard + +SCOUT's analysis dashboard now spans the three Process Learning Levels. The same screen reads the **system outcome** (L1 Y), the **process flow** (L2 X), and the **local mechanism** (L3 x) — without re-rendering the workspace for each level and without the analyst leaving SCOUT. + +> **Journey phase:** SCOUT — multi-level reading. FRAME owns L2 authoring; Investigation Wall owns L3 hypothesis canvas. + +--- + +## What "multi-level" means here + +Each VariScout surface owns exactly one level and lenses the others by linking, never by re-rendering. The boundary is enforced structurally; see [ADR-074](../../07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md). + +| Level | Owned by | Lensed by | +| ---------------------- | ------------------------------------------------ | ---------------------------------------------------- | +| **L1 — Outcome (Y)** | SCOUT dashboard | Hub Capability tab, Investigation Wall, Evidence Map | +| **L2 — Flow (X)** | FRAME (authoring) + Hub Capability tab (reading) | SCOUT chrome, Investigation Wall | +| **L3 — Mechanism (x)** | Investigation Wall | SCOUT (factor selectors), Evidence Map, INVESTIGATE | + +In SCOUT V1, the multi-level reading is delivered by: + +1. **Scope detection** — the strategy detects whether the data covers a baseline (B0), a single-node investigation (B1), or multiple nodes (B2) and routes data to the four chart slots accordingly. No new charts; the existing four-slot grid stays the same. +2. **Timeline windows** — every chart, every metric, and every Finding agrees on the same temporal scope. See [Timeline Windows in Investigations](timeline-window-investigations.md). +3. **Throughput basics** — `computeOutputRate` and `computeBottleneck` ship in V1 to give L2 (flow) reading a baseline. Cycle time, FPY, RTY arrive in the second slice; OEE, takt, lead time, and WIP in the third. See spec §8. + +--- + +## How the surfaces relate + +SCOUT, the Process Hub Capability tab, the Evidence Map, and the Investigation Wall are **peers** in V1 — not siblings inside one pane. The dashboard is the entry point; the others are link targets. + +| Surface | Role | +| ---------------------- | ------------------------------------------------------------------------------------------------- | +| **SCOUT dashboard** | Outcome reading. Four chart slots. Timeline window in chrome. Findings saved with window context. | +| **Hub Capability tab** | Flow reading. Per-step Cpk distribution, capability over time, hub-time rolling default. | +| **Evidence Map** | Factor network. Click a factor → focus its statistical context. Receives links from Findings. | +| **Investigation Wall** | Hypothesis canvas. Hubs accumulate evidence (data + Gemba + expert) per SuspectedCause. | + +Crossing surfaces preserves the active timeline window. When you click from a Finding to its Evidence Map factor, or from a Hub Capability bar to a SuspectedCause hub, the window context travels with you. Drift detection runs on entry: if today's window has shifted more than 20% from the saved Finding context, the Finding flags it. + +--- + +## What V1 does not change + +- The four chart slots (I-Chart, Variation Sources, Adaptive Lens, Pareto/Capability) keep their roles. +- Drill A (drill into a single factor) keeps its existing semantics. +- No new chart types. No Drill C, no Plan D, no per-mode multi-level expansion beyond Standard EDA. +- FRAME thin-spot helpers stay deferred — see decision-log §5. + +--- + +## When to use which surface + +- **Outcome question** ("Is the customer-facing measurement in spec? When did it shift?") → SCOUT dashboard with the timeline picker. +- **Flow question** ("Which step is the bottleneck? Which step has the worst Cpk?") → Process Hub Capability tab. +- **Mechanism question** ("What evidence supports this suspected cause? What's missing?") → Investigation Wall. +- **Factor question** ("Which factors drive this Y? How are they connected?") → Evidence Map. + +The dashboard does not try to answer all four — it is the L1 reading surface. The other three are one click away. + +--- + +## See also + +- [Timeline Windows in Investigations](timeline-window-investigations.md) +- [Timeline Window Architecture](../../05-technical/architecture/timeline-window-architecture.md) +- [Multi-level SCOUT design spec](../../superpowers/specs/2026-04-29-multi-level-scout-design.md) +- [ADR-074 — surface boundary policy](../../07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md) +- [Process Hub Capability](process-hub-capability.md) +- [Statistics Panel](stats-panel.md) diff --git a/docs/03-features/analysis/timeline-window-investigations.md b/docs/03-features/analysis/timeline-window-investigations.md new file mode 100644 index 000000000..60e5ce364 --- /dev/null +++ b/docs/03-features/analysis/timeline-window-investigations.md @@ -0,0 +1,75 @@ +--- +title: Timeline Windows in Investigations +audience: [analyst] +category: analysis +status: delivered +related: [multi-level-dashboard, capability, process-hub-capability, staged-analysis] +--- + +# Timeline Windows in Investigations + +VariScout investigations now carry an explicit **timeline window** — the period of time the analysis applies to. The window is a first-class part of the investigation alongside filters, factors, and findings. Choose the window once and every chart on the dashboard, every Finding you save, and every drift comparison agrees on the same temporal scope. + +> **Journey phase:** SCOUT (and Process Hub Capability tab) — answers Watson's third question, _"When does this happen?"_ + +--- + +## Where the picker lives + +The timeline-window picker lives in the **dashboard chrome above the chart grid**, in `DashboardLayoutBase`. It is _not_ part of `FilterContextBar` — that bar stays focused on factor filters and exports as a per-chart summary. The window picker is a peer of the filter chips and the mode selector: a single control that affects every chart in the dashboard at once. + +Findings record the active window at the time they were saved. When you re-open a Finding later, the footer shows the original window and flags drift if today's window now disagrees with the data shape it was created in. + +--- + +## The four window kinds + +| Kind | Shape | When to use | +| -------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| **Fixed** | `[start, end]` — explicit dates | Closing a PPAP submission, comparing Q1 vs Q2, before/after a known process change. | +| **Rolling** | "Last N days/weeks/months" | Live monitoring on the Process Hub. Default for hub-time so the cadence (weekly, monthly) matches the review rhythm. | +| **Open-ended** | `[start, …)` — start, no end | Investigation-time. The default for new investigations: capture everything since the issue surfaced and let it grow. | +| **Cumulative** | All data through `end` | Baseline (B0) reads, regression baselining, and capability snapshots that intentionally include all history. | + +Each kind is a tagged variant of the `TimelineWindow` discriminated union, so the engine can apply the same `applyWindow()` to whichever shape the analyst chose. There is no separate "timeline filter" type — the window _is_ the temporal scope. + +--- + +## Defaults per surface + +V1 ships with sensible defaults so the picker rarely needs touching: + +- **Investigation dashboard (SCOUT):** `open-ended`, starting at the earliest row in the data. The investigation grows as new data arrives. +- **Process Hub Capability tab (hub-time):** `rolling`, matched to the hub's cadence. Weekly cadence → last 7 days. Monthly cadence → last 30 days. +- **Production-line-glance baseline (B0):** `cumulative`. The baseline read intentionally includes all history. + +Override the default at any time via the picker in the dashboard chrome. The choice is **session-local** in V1: it does not persist across reloads. V2 will persist the window inside the investigation envelope so re-opening an investigation restores the same window. + +--- + +## How the choice flows through + +1. The picker writes to the active investigation (or the hub-time surface) via `useTimelineWindow`. +2. The strategy's `dataRouter` reads the window and passes filtered rows to every metric module — capability, throughput, drift, output-rate. +3. Findings saved while a window is active record a `WindowContext` (the window kind + start/end + active filter chips). +4. Drift detection (`computeFindingWindowDrift`) compares the saved Finding context against today's window when you re-open the Finding. If the relative change exceeds 20% (default threshold), the Finding's footer flags drift and CoScout offers to re-run the analysis on the current window. + +The window is the same primitive used by re-upload (append-mode merge) so additions are deduplicated against the existing window's data range. + +--- + +## Common patterns + +- **Closing a finding for PPAP:** switch the window from `open-ended` to `fixed [start, end]` once the analysis period is locked. Existing Findings record the lock-in window. +- **Watching the hub in real time:** leave the hub-time picker on `rolling`. Each visit shows the most recent N days against current specs. +- **Comparing before/after an improvement:** save a Finding under the "before" `fixed` window, run improvement, then switch to the "after" `fixed` window. The Finding footer will flag drift, making the comparison explicit. + +--- + +## See also + +- [Multi-Level Dashboard](multi-level-dashboard.md) — how SCOUT spans levels and where the timeline picker sits. +- [Timeline Window Architecture](../../05-technical/architecture/timeline-window-architecture.md) — the engineer-facing contract. +- [Multi-level SCOUT design spec](../../superpowers/specs/2026-04-29-multi-level-scout-design.md) — full design context. +- [ADR-074](../../07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md) — surface boundary policy. +- [Staged Analysis](staged-analysis.md) — the related but distinct "phases inside one window" pattern. diff --git a/docs/03-features/learning/glossary.md b/docs/03-features/learning/glossary.md index 5179e108a..a48cd00cb 100644 --- a/docs/03-features/learning/glossary.md +++ b/docs/03-features/learning/glossary.md @@ -190,6 +190,19 @@ Terms are organized by priority for AI Phase 1 launch. Each term will follow the --- +## Multi-Level SCOUT Terms (added 2026-04-30) + +| Term ID | Label | Definition | +| ------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `bottleneck` | Bottleneck | The slowest step in a multi-node process — the step with the lowest output rate over the active timeline window. Computed by `computeBottleneck` in `@variscout/core/throughput`. | +| `findingDrift` | Finding Drift | The relative change between a Finding's saved window context and the current window context. Default threshold 0.20: when exceeded, the Finding's footer flags drift and CoScout offers to re-run the analysis on the current window. | +| `hubTime` | Hub-Time | The temporal scope of a Process Hub Capability tab. Defaults to a `rolling` window matched to the hub's review cadence (weekly hub → last 7 days; monthly hub → last 30 days). Distinct from investigation-time, which is per-investigation. | +| `investigationTime` | Investigation-Time | The temporal scope of a single SCOUT investigation. Defaults to `open-ended` (start at the earliest row, no end) so the investigation grows as new data arrives. Distinct from hub-time. | +| `outputRate` | Output Rate | Outputs per unit time over the active timeline window. Computed by `computeOutputRate` in `@variscout/core/throughput`. The basic L2 (flow) reading metric in V1; cycle time, FPY, RTY arrive in subsequent slices. | +| `timelineWindow` | Timeline Window | The explicit temporal scope of an investigation or hub-time view. A tagged variant of `fixed`, `rolling`, `open-ended`, or `cumulative`. Travels with every chart, every Finding, and every drift comparison so the analysis answers "when does this happen?" precisely. | + +--- + ## Mobile Tooltip Behavior On phone (<640px), the HelpTooltip ⓘ icon uses touch-toggle interaction instead of hover. diff --git a/docs/05-technical/architecture/timeline-window-architecture.md b/docs/05-technical/architecture/timeline-window-architecture.md new file mode 100644 index 000000000..8ea5a57a1 --- /dev/null +++ b/docs/05-technical/architecture/timeline-window-architecture.md @@ -0,0 +1,168 @@ +--- +title: Timeline Window & Multi-Level Routing Architecture +audience: [developer] +category: architecture +status: delivered +related: [analysis-strategy, dashboard-layout, data-pipeline-map, mental-model-hierarchy] +--- + +# Timeline Window & Multi-Level Routing Architecture + +Engineer-facing reference for how multi-level SCOUT routes data and how the timeline-window primitive plugs into every metric module. + +For the user-facing intent, see [Timeline Windows in Investigations](../../03-features/analysis/timeline-window-investigations.md) and [Multi-Level Dashboard](../../03-features/analysis/multi-level-dashboard.md). For the design rationale and surface boundary policy, see [ADR-074](../../07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md). + +--- + +## The `dataRouter` contract + +`AnalysisModeStrategy` (in `packages/core/src/analysisStrategy.ts`) gains a single new method: + +```ts +type DataRouterArgs = { + rows: ParsedRow[]; + scope: Scope; // 'b0' | 'b1' | 'b2' + phase: AnalysisPhase; // 'frame' | 'scout' | 'investigate' | 'improve' + window: TimelineWindow; + context: SpecLookupContext; +}; + +interface AnalysisModeStrategy { + // existing fields… + dataRouter(args: DataRouterArgs): Record; +} +``` + +`dataRouter` is the single point where window + scope + context combine to decide what each chart slot receives. It does not introduce runtime hook switching: components keep importing the same hooks they always did. The router replaces _data shape_ per slot, never _component identity_. + +`useDataRouter` (in `@variscout/hooks`) is a thin sanity-check wrapper used by `DashboardLayoutBase`. It asserts that the strategy returned a `ChartInput` for each declared slot and surfaces a typed error in dev if not. It is _not_ a runtime dispatcher — slots still mount through their normal component path. + +--- + +## Scope detection + +`detectScope(investigation, processMap)` in `@variscout/core` returns: + +- **`b0`** — baseline. No node mappings, or the mappings cover the entire process. Cumulative is the natural default. +- **`b1`** — single-node investigation. One canonical-map node; all data lives under it. +- **`b2`** — multi-node investigation. Multiple nodes; the dashboard becomes flow-aware (per-step distributions, bottleneck detection). + +Scope is a derived value, never persisted. Strategies and router branches read it at render time. + +--- + +## The `TimelineWindow` discriminated union + +```ts +type TimelineWindow = + | { kind: 'fixed'; start: number; end: number } + | { kind: 'rolling'; durationMs: number; anchor?: number } + | { kind: 'open-ended'; start: number } + | { kind: 'cumulative'; end?: number }; +``` + +`applyWindow(rows, window, timeColumn)` filters rows by the parser-detected `timeColumn` (in `packages/core/src/time.ts`). The function is referentially transparent — given the same rows + window + column, it returns identical output. There is no separate "filter" stage for time; the window _is_ the temporal filter. + +Defaults per surface (locked in spec §8 / plan Task 0.1): + +| Surface | Default window | +| ------------------------------------- | ---------------------------- | +| Investigation dashboard (SCOUT) | `open-ended` | +| Process Hub Capability tab (hub-time) | `rolling` matched to cadence | +| Production-line-glance baseline (B0) | `cumulative` | + +V1 stores the active window as session-local React state on the dashboard. V2 will lift it into the investigation envelope; see plan Task 0.2 for the locked attachment point. + +--- + +## Append-mode `mergeRows` + +Re-uploading a CSV does not replace the dataset. `mergeRows(existing, incoming, keys)` (in `packages/core/src/parser`) deduplicates against a composite key: + +``` +key = timestamp + factor columns + outcome column +``` + +(Locked in plan Task 0.3.) The function emits a `MergeReport`: + +```ts +type MergeReport = { + added: number; + duplicates: number; + corrections: number; // existing keys with different outcome values +}; +``` + +The chrome-walk surfaces the report after re-upload. A correction is treated as an explicit edit, not a duplicate — the user is shown a diff prompt before the row is overwritten. + +--- + +## Drift detection + +Findings record a `WindowContext` (window + active filters + spec hash) at save time. `computeFindingWindowDrift(savedContext, currentContext)` (in `packages/core/src/findings/drift.ts`) returns: + +```ts +type DriftResult = + | { kind: 'aligned' } + | { kind: 'drifted'; relativeChange: number; reasons: DriftReason[] }; +``` + +Default drift threshold is **0.20 relative change** (locked in plan Task 5.1). When drift is detected, the Finding footer flags it and the Wall handoff badge shifts colour. CoScout consumes `DriftResult` as a structured input in V2; V1 only surfaces it visually. + +--- + +## Throughput module + +`packages/core/src/throughput/` ships in V1 with two functions: + +- `computeOutputRate(rows, window, timeColumn)` — outputs per unit time over the window. +- `computeBottleneck(rows, processMap, window)` — slowest step (lowest output rate) given a multi-node scope. + +Both consume the windowed rows directly; they do not re-implement filtering. Cycle time, FPY, RTY (second slice) and OEE / takt / lead time / WIP (third slice) extend this module. They are deferred to subsequent plan slices. + +--- + +## How metric modules plug in + +Each strategy declares the metric modules its slots need via a `transforms[]` array of strings. The router resolves transform IDs to module functions and passes windowed rows. New metrics (e.g. throughput) need: + +1. A pure function in the appropriate `packages/core/src//` directory. +2. A registered transform ID in the strategy's `transforms[]`. +3. Tests under `packages/core/src//__tests__/`. + +No chart code changes. The component receives the new `ChartInput` shape and renders it. + +--- + +## Boundary keeping + +ADR-074 declares structural-absence rules per surface. A CI script ships at `scripts/check-level-boundaries.sh` (committed `6c682187`) and runs in pre-commit. It greps for forbidden imports — for example, SCOUT importing `processMap` editing helpers, or Evidence Map importing capability computation. Failures block the commit. + +--- + +## Key files + +| Concern | File | +| --------------------- | ------------------------------------------------------------------ | +| Strategy + dataRouter | `packages/core/src/analysisStrategy.ts` | +| Scope detection | `packages/core/src/scope/detectScope.ts` | +| TimelineWindow types | `packages/core/src/timeline/index.ts` | +| applyWindow | `packages/core/src/timeline/applyWindow.ts` | +| Append-mode merge | `packages/core/src/parser/mergeRows.ts` | +| Drift detection | `packages/core/src/findings/drift.ts` | +| Throughput | `packages/core/src/throughput/` | +| useTimelineWindow | `packages/hooks/src/useTimelineWindow.ts` | +| useDataRouter | `packages/hooks/src/useDataRouter.ts` | +| TimelineWindowPicker | `packages/ui/src/components/TimelineWindowPicker/` | +| DashboardLayoutBase | `packages/ui/src/components/DashboardBase/DashboardLayoutBase.tsx` | +| Boundary check | `scripts/check-level-boundaries.sh` | + +--- + +## Related + +- [ADR-074 — SCOUT level-spanning surface boundary policy](../../07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md) +- [ADR-073 — no statistical roll-up across heterogeneous units](../../07-decisions/adr-073-no-statistical-rollup-across-heterogeneous-units.md) +- [Multi-level SCOUT design spec](../../superpowers/specs/2026-04-29-multi-level-scout-design.md) +- [Dashboard Layout Architecture](dashboard-layout.md) +- [Mental Model Hierarchy](mental-model-hierarchy.md) diff --git a/docs/07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md b/docs/07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md index 1fd18317c..fbb6fd3dd 100644 --- a/docs/07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md +++ b/docs/07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md @@ -101,7 +101,7 @@ The policy is enforceable by structural absence (mirroring ADR-073's verificatio - `rg "stratifyByFactor|factorEdge|factorRelationship" packages/ui/src/components/DashboardBase/` returns zero hits — SCOUT does not reimplement Evidence Map's factor-network rendering. - `rg "hypothesisCanvas|suspectedCauseHub|gateNode" packages/ui/src/components/Frame*/` returns zero hits — FRAME does not embed hypothesis canvas surfaces. - `rg "LayeredProcessView|OperationsBand" packages/ui/src/components/EvidenceMap/` returns zero hits — Evidence Map does not reimplement L2 flow rendering. -- A CI check (or pre-commit script) at `scripts/check-level-boundaries.sh` (to be added when the implementation plan lands) verifies the structural-absence claims continuously. +- A CI check (or pre-commit script) at `scripts/check-level-boundaries.sh` verifies the structural-absence claims continuously. The script ships with the multi-level SCOUT V1 first-slice implementation and runs in pre-commit. These checks become meaningful once the relevant component directories are created — `InvestigationWall/`, `DashboardBase/`, `EvidenceMap/`, and `Frame*/` are referenced by the spec but do not all exist in the current codebase. Until they do, the absence of the directories is itself structural enforcement; the rg patterns are forward-looking guards that activate as the first-slice implementation lands. diff --git a/docs/USER-JOURNEYS-CAPABILITY.md b/docs/USER-JOURNEYS-CAPABILITY.md index 61e1bd4ba..7508885c7 100644 --- a/docs/USER-JOURNEYS-CAPABILITY.md +++ b/docs/USER-JOURNEYS-CAPABILITY.md @@ -23,6 +23,10 @@ The engineer wants to answer three questions: Does my process fit within the cus After uploading data and completing column mapping, the engineer enters LSL, USL, and optionally a target. The characteristic type determines which limits apply: nominal (two-sided, Cp and Cpk both calculate), smaller-is-better (USL only, Cp = Cpk), or larger-is-better (LSL only, Cp = Cpk). Without at least one spec limit, capability indices are suppressed. +### Choosing the timeline window + +Before reading capability, the engineer chooses a [timeline window](03-features/analysis/timeline-window-investigations.md) in the dashboard chrome. On the Process Hub Capability tab, the default is `rolling` matched to the hub cadence (weekly hub → last 7 days; monthly hub → last 30 days). For PPAP submissions the engineer typically switches to `fixed [start, end]` once the analysis period is locked. Findings record the active window so before/after capability comparisons stay explicit and drift between saved and current windows is flagged. + ### Reading the standard capability view The laptop-first dashboard keeps the I-Chart visible and places capability reading in the adaptive right-hand lens. When specs exist, the lens labels the histogram tab **Capability** and pairs it with **Probability**. The top strip summarizes Cp/Cpk context and provides the spec shortcut. diff --git a/docs/USER-JOURNEYS.md b/docs/USER-JOURNEYS.md index c527c3719..58f9c0b48 100644 --- a/docs/USER-JOURNEYS.md +++ b/docs/USER-JOURNEYS.md @@ -119,7 +119,10 @@ Every investigation - Standard, Yamazumi, Performance, Defect, Capability, or Pr 2. **SCOUT.** Data is parsed (wide-form, stack columns, defect events all supported). Characteristic types are inferred. Analysis modes surface variation, capability, flow, defect, or work-content patterns. First clues - and questions emerge. + and questions emerge. The dashboard chrome carries a [timeline-window picker](03-features/analysis/timeline-window-investigations.md) + (fixed / rolling / open-ended / cumulative) so every chart, every Finding, + and every drift comparison agrees on the same temporal scope. Investigation-time + defaults to `open-ended`. 3. **INVESTIGATE.** User builds one or more Mechanism Branches or SuspectedCause hubs. Each hub accumulates evidence: data (Evidence Map edges diff --git a/docs/decision-log.md b/docs/decision-log.md index ff0e49a39..7118f26c5 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -94,30 +94,30 @@ Features deferred with intent to remember. Each leaves by getting a spec or bein The running list of conversations / work, with lifecycle state. Done items stay with a closed-date for provenance; dropped items capture why. Each row links back to its source decision-log entry, so closing the work also resolves the upstream question. New conversations get logged as `queued` at session start; transition to `in-flight` when work begins; close with a link to the plan-file / PR / commit when done. -| Topic | Type | State | Source | Opened | Closed | -| ----------------------------------------------------------------------------------------------- | -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ---------- | -| Investigation Wall methodology integration brainstorm | brainstorm | queued | §2 Open Questions — Investigation Wall methodology | 2026-04-29 | — | -| MSA naming + scope | brainstorm | queued | §2 Open Questions — MSA naming | 2026-04-29 | — | -| Sample-size / power planning shape | brainstorm | queued | §2 Open Questions — Sample-size planning | 2026-04-29 | — | -| Question-data-fit assessment placement | brainstorm | queued | §2 Open Questions — Question-data fit | 2026-04-29 | — | -| Watson B5 — CoScout structural autonomy boundary | brainstorm | queued | §2 Open Questions — Watson B5 | 2026-04-29 | — | -| Watson B4 — No-data-team Evidence Source workflow | brainstorm | queued | §2 Open Questions — Watson B4 | 2026-04-29 | — | -| Watson G11 — JD Powers severity-weighting | brainstorm | queued | §2 Open Questions — Watson G11 | 2026-04-29 | — | -| FRAME thin-spot batch + B2 chrome walk | implementation | deferred | §1 Replayed Decisions — C3 supersession; superseded by [`docs/superpowers/specs/2026-04-29-multi-level-scout-design.md`](superpowers/specs/2026-04-29-multi-level-scout-design.md) §5 (FRAME thin-spot helpers split honestly across phases) + first-slice implementation. The four C3-superseded helpers are now phase-assigned (`processHubId` → app chrome; `suggestNodeMappings` + USL/LSL hint + type-integrity check → FRAME; statistical-character signals → SCOUT boxplot annotations) and feed `detectScope()` at FRAME ahead of the multi-level architecture work. | 2026-04-29 | 2026-04-29 | -| Multi-level SCOUT spec drafting + cross-links + ADR-074 | design | done | §3 Named-Future — Multi-level SCOUT second-slice / third-slice metrics; spec at [`docs/superpowers/specs/2026-04-29-multi-level-scout-design.md`](superpowers/specs/2026-04-29-multi-level-scout-design.md) + [`docs/07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md`](07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md). Landed in commit `b23558b0`. | 2026-04-29 | 2026-04-29 | -| Multi-level SCOUT V1 implementation plan + execution | implementation | queued | Plan: [`docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md`](superpowers/plans/2026-04-29-multi-level-scout-v1.md) — derived from [`docs/superpowers/specs/2026-04-29-multi-level-scout-design.md`](superpowers/specs/2026-04-29-multi-level-scout-design.md) §8 Sequencing first slice (architecture + window primitive + L2 throughput basics: `dataRouter` extension, `TimelineWindow` types + persistence, `detectScope()`, window-context recorded on Findings, hub-time default windows, new `throughput/` module shipping `computeOutputRate` + `computeBottleneck`, `findings/drift.ts`, append-mode for re-upload, `ProductionLineGlanceDashboard` refactored onto strategy + dataRouter pattern). 16 tasks; six "Open in spec" ambiguities resolved upfront in Task 0. | 2026-04-29 | — | -| Phase 6 v2 / S5 — re-mount review/handoff editors | implementation | queued | §3 Named-Future — Phase 6 v2 / S5 | 2026-04-29 | — | -| Drill C V1 — recursive ProcessMap navigation | implementation | queued | §3 Named-Future — Drill C V1 | 2026-04-29 | — | -| Plan D / Org Hub-of-Hubs view | implementation | queued | §3 Named-Future — Plan D | 2026-04-29 | — | -| Supersession audit | audit | queued | §2 Open Questions — Supersession audit | 2026-04-29 | — | -| Doc-hygiene sweep of `docs/superpowers/specs/` | audit | queued | §2 Open Questions — Doc-hygiene sweep | 2026-04-29 | — | -| ADRs 060 / 064 / 068 / 070 W6 amendments verification | audit | queued | §2 Open Questions — W6 amendments verification | 2026-04-29 | — | -| Process tier framing consistency | audit | queued | §2 Open Questions — Process tier framing | 2026-04-29 | — | -| Persona-flow updates for B2 single-node investigations | doc-update | queued | §1 Replayed Decisions — C3 supersession (B2 chrome-walk implication) | 2026-04-29 | — | -| Use-case docs — production-line-glance cadence + Wall detective mode + Process Hub cadence flow | doc-update | queued | §5 User Journey Map — stale rows | 2026-04-29 | — | -| Per-mode journey doc updates (USER-JOURNEYS-{mode}.md reflecting ADR-073 scope/drill semantics) | doc-update | queued | §1 Replayed Decisions — ADR-073 | 2026-04-29 | — | -| Ruflo stale-path drift detector (companion to memory staleness check) | tooling | queued | §2 Open Questions — Ruflo stale-path drift detector | 2026-04-29 | — | -| Dependabot housekeeping — verify and merge #87 / #83 / #85 (rebase first); evaluate #86 | implementation | queued | Morning plan Track A — `~/.claude/plans/lets-evaluate-where-we-deep-pascal.md`. PR #81 merged 2026-04-29 (`1dd2f711`); #87 stale-branch verification failed (9 commits behind main) and was paused. Next: rebase each onto main, then run `bash scripts/pr-ready-check.sh`. Hold #86 (TypeScript 5.9.3 → 6.0.3 major) for separate evaluation. | 2026-04-29 | — | +| Topic | Type | State | Source | Opened | Closed | +| ----------------------------------------------------------------------------------------------- | -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ---------- | +| Investigation Wall methodology integration brainstorm | brainstorm | queued | §2 Open Questions — Investigation Wall methodology | 2026-04-29 | — | +| MSA naming + scope | brainstorm | queued | §2 Open Questions — MSA naming | 2026-04-29 | — | +| Sample-size / power planning shape | brainstorm | queued | §2 Open Questions — Sample-size planning | 2026-04-29 | — | +| Question-data-fit assessment placement | brainstorm | queued | §2 Open Questions — Question-data fit | 2026-04-29 | — | +| Watson B5 — CoScout structural autonomy boundary | brainstorm | queued | §2 Open Questions — Watson B5 | 2026-04-29 | — | +| Watson B4 — No-data-team Evidence Source workflow | brainstorm | queued | §2 Open Questions — Watson B4 | 2026-04-29 | — | +| Watson G11 — JD Powers severity-weighting | brainstorm | queued | §2 Open Questions — Watson G11 | 2026-04-29 | — | +| FRAME thin-spot batch + B2 chrome walk | implementation | deferred | §1 Replayed Decisions — C3 supersession; superseded by [`docs/superpowers/specs/2026-04-29-multi-level-scout-design.md`](superpowers/specs/2026-04-29-multi-level-scout-design.md) §5 (FRAME thin-spot helpers split honestly across phases) + first-slice implementation. The four C3-superseded helpers are now phase-assigned (`processHubId` → app chrome; `suggestNodeMappings` + USL/LSL hint + type-integrity check → FRAME; statistical-character signals → SCOUT boxplot annotations) and feed `detectScope()` at FRAME ahead of the multi-level architecture work. | 2026-04-29 | 2026-04-29 | +| Multi-level SCOUT spec drafting + cross-links + ADR-074 | design | done | §3 Named-Future — Multi-level SCOUT second-slice / third-slice metrics; spec at [`docs/superpowers/specs/2026-04-29-multi-level-scout-design.md`](superpowers/specs/2026-04-29-multi-level-scout-design.md) + [`docs/07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md`](07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md). Landed in commit `b23558b0`. | 2026-04-29 | 2026-04-29 | +| Multi-level SCOUT V1 implementation plan + execution | implementation | done | Plan: [`docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md`](superpowers/plans/2026-04-29-multi-level-scout-v1.md) — derived from [`docs/superpowers/specs/2026-04-29-multi-level-scout-design.md`](superpowers/specs/2026-04-29-multi-level-scout-design.md) §8 Sequencing first slice (architecture + window primitive + L2 throughput basics: `dataRouter` extension, `TimelineWindow` types + persistence, `detectScope()`, window-context recorded on Findings, hub-time default windows, new `throughput/` module shipping `computeOutputRate` + `computeBottleneck`, `findings/drift.ts`, append-mode for re-upload, `ProductionLineGlanceDashboard` refactored onto strategy + dataRouter pattern). 16 tasks; six "Open in spec" ambiguities resolved upfront in Task 0. Spec promoted draft → delivered 2026-04-30. Chrome-walk pending. | 2026-04-29 | 2026-04-30 | +| Phase 6 v2 / S5 — re-mount review/handoff editors | implementation | queued | §3 Named-Future — Phase 6 v2 / S5 | 2026-04-29 | — | +| Drill C V1 — recursive ProcessMap navigation | implementation | queued | §3 Named-Future — Drill C V1 | 2026-04-29 | — | +| Plan D / Org Hub-of-Hubs view | implementation | queued | §3 Named-Future — Plan D | 2026-04-29 | — | +| Supersession audit | audit | queued | §2 Open Questions — Supersession audit | 2026-04-29 | — | +| Doc-hygiene sweep of `docs/superpowers/specs/` | audit | queued | §2 Open Questions — Doc-hygiene sweep | 2026-04-29 | — | +| ADRs 060 / 064 / 068 / 070 W6 amendments verification | audit | queued | §2 Open Questions — W6 amendments verification | 2026-04-29 | — | +| Process tier framing consistency | audit | queued | §2 Open Questions — Process tier framing | 2026-04-29 | — | +| Persona-flow updates for B2 single-node investigations | doc-update | queued | §1 Replayed Decisions — C3 supersession (B2 chrome-walk implication) | 2026-04-29 | — | +| Use-case docs — production-line-glance cadence + Wall detective mode + Process Hub cadence flow | doc-update | queued | §5 User Journey Map — stale rows | 2026-04-29 | — | +| Per-mode journey doc updates (USER-JOURNEYS-{mode}.md reflecting ADR-073 scope/drill semantics) | doc-update | queued | §1 Replayed Decisions — ADR-073 | 2026-04-29 | — | +| Ruflo stale-path drift detector (companion to memory staleness check) | tooling | queued | §2 Open Questions — Ruflo stale-path drift detector | 2026-04-29 | — | +| Dependabot housekeeping — verify and merge #87 / #83 / #85 (rebase first); evaluate #86 | implementation | queued | Morning plan Track A — `~/.claude/plans/lets-evaluate-where-we-deep-pascal.md`. PR #81 merged 2026-04-29 (`1dd2f711`); #87 stale-branch verification failed (9 commits behind main) and was paused. Next: rebase each onto main, then run `bash scripts/pr-ready-check.sh`. Hold #86 (TypeScript 5.9.3 → 6.0.3 major) for separate evaluation. | 2026-04-29 | — | --- diff --git a/docs/llms.txt b/docs/llms.txt index 7d3ceb974..fdefaef53 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -36,6 +36,9 @@ Entry points for AI agents working in this repo. Humans should start at `docs/in ## Reference +- [Timeline Windows in Investigations](03-features/analysis/timeline-window-investigations.md): user-facing reference for the four window kinds and where the picker lives. +- [Multi-Level Dashboard](03-features/analysis/multi-level-dashboard.md): user-facing reference for how SCOUT spans levels and links to Hub Capability / Evidence Map / Investigation Wall. +- [Timeline Window & Multi-Level Routing Architecture](05-technical/architecture/timeline-window-architecture.md): engineer-facing reference for `dataRouter`, `detectScope`, TimelineWindow, throughput, drift detection. - [Glossary](03-features/learning/glossary.md): domain terms, statistical vocabulary. - [Feature parity matrix](08-products/feature-parity.md): PWA vs Azure Standard vs Azure Team. - [Statistics reference](05-technical/statistics-reference.md): formulas, NIST validation, citations. diff --git a/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md b/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md index 65dc5267c..7b203fd3c 100644 --- a/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md +++ b/docs/superpowers/plans/2026-04-29-multi-level-scout-v1.md @@ -1931,7 +1931,7 @@ git commit -m "chore: ADR-074 boundary check script + pre-commit wiring" - Modify: `docs/decision-log.md` — close V1 implementation row in §4 (state `done`, Closed `2026-04-29`); update SCOUT Journey Map row to `shipped (multi-level V1)` with chrome-walk date. - Modify: `docs/07-decisions/adr-074-scout-level-spanning-surface-boundary-policy.md` — strike "to be added" note now that the script ships. -- [ ] **Step 16.1: Write feature docs (user-facing)** +- [x] **Step 16.1: Write feature docs (user-facing)** Create `docs/03-features/analysis/timeline-window-investigations.md` with frontmatter (audience: user) — explain the four window types, when to use each, and where the picker lives. @@ -1939,11 +1939,11 @@ Create `docs/03-features/analysis/multi-level-dashboard.md` — explain how clic (Each doc needs proper frontmatter per `scripts/docs-frontmatter-schema.mjs`.) -- [ ] **Step 16.2: Write architecture doc (engineer-facing)** +- [x] **Step 16.2: Write architecture doc (engineer-facing)** Create `docs/05-technical/architecture/timeline-window-architecture.md` — the `dataRouter` contract, the strategy + dataRouter integration, scope detection, how new metric modules plug in. Mirror the style of existing technical docs. -- [ ] **Step 16.3: Update vision + glossary** +- [x] **Step 16.3: Update vision + glossary** Edit `docs/01-vision/methodology.md` — add a short paragraph on temporal scope as part of Watson's third question. @@ -1951,13 +1951,13 @@ Edit `docs/01-vision/eda-mental-model.md` — note that SCOUT loops gain window Edit `docs/03-features/learning/glossary.md` and `packages/core/src/glossary/terms.ts` — add: timeline window, output rate, bottleneck, finding drift, hub-time, investigation-time. -- [ ] **Step 16.4: Update journey docs** +- [x] **Step 16.4: Update journey docs** Edit `docs/USER-JOURNEYS.md`, `docs/USER-JOURNEYS-CAPABILITY.md` — mention the timeline picker in the journey spine. (Other per-mode files get full updates in V3 — V1 only updates Standard EDA + Capability journey.) -- [ ] **Step 16.5: Update agent / package CLAUDE.md files** +- [x] **Step 16.5: Update agent / package CLAUDE.md files** Edit `docs/llms.txt` — add new feature doc paths + architecture doc as priority entry points. @@ -1968,7 +1968,7 @@ Edit `apps/azure/CLAUDE.md` and `apps/pwa/CLAUDE.md` (mention the multi-level su (Each edit is 1-3 sentences; preserve the host file's voice.) -- [ ] **Step 16.6: Lifecycle updates** +- [x] **Step 16.6: Lifecycle updates** In `docs/superpowers/specs/2026-04-29-multi-level-scout-design.md`: change `status: draft` to `status: delivered`. Update `last-reviewed`. @@ -1982,7 +1982,7 @@ Add a memory entry at `~/.claude/projects/.../memory/project_multi_level_scout.m Update `~/.claude/projects/.../memory/MEMORY.md` index — add the new entry; remove or supersede the older FRAME thin-spot entry per the deferral. -- [ ] **Step 16.8: Run final pre-merge gate** +- [x] **Step 16.8: Run final pre-merge gate** ```bash bash scripts/pr-ready-check.sh diff --git a/docs/superpowers/specs/2026-04-29-multi-level-scout-design.md b/docs/superpowers/specs/2026-04-29-multi-level-scout-design.md index ed6efcd30..2b481612f 100644 --- a/docs/superpowers/specs/2026-04-29-multi-level-scout-design.md +++ b/docs/superpowers/specs/2026-04-29-multi-level-scout-design.md @@ -2,8 +2,9 @@ title: Multi-level SCOUT — design audience: [product, designer, engineer, analyst] category: design-spec -status: draft +status: delivered date: 2026-04-29 +last-reviewed: 2026-04-30 related: - process-learning-operating-model - investigation-scope-and-drill-semantics diff --git a/packages/core/CLAUDE.md b/packages/core/CLAUDE.md index 0c8c10974..329a97d50 100644 --- a/packages/core/CLAUDE.md +++ b/packages/core/CLAUDE.md @@ -12,7 +12,7 @@ Pure TypeScript domain layer. Stats, parser, glossary, tier, i18n, findings, var ## Invariants - Sub-path exports are public API. Adding a new sub-path requires updating `packages/core/package.json` exports field + `tsconfig.json` paths. -- Available sub-paths: root (barrel), /stats, /ai, /parser, /findings, /variation, /yamazumi, /tier, /types, /i18n, /glossary, /export, /navigation, /responsive, /performance, /time, /projectMetadata, /strategy, /ui-types, /evidenceMap, /defect. +- Available sub-paths: root (barrel), /stats, /ai, /parser, /findings (incl. `findings/drift.ts`), /variation, /yamazumi, /tier, /types, /i18n, /glossary, /export, /navigation, /responsive, /performance, /time, /timeline (TimelineWindow + applyWindow), /throughput (computeOutputRate, computeBottleneck), /projectMetadata, /strategy, /ui-types, /evidenceMap, /defect. - `resolveMode()` + `getStrategy()` in `src/analysisStrategy.ts` is the mode dispatch point. New analysis modes must register here. - The stats engine is the authority for numeric claims. CoScout receives stat results; it does not recompute. - Numeric safety has three boundaries (ADR-069): B1 parser rejects NaN via `toNumericValue`; B2 stats functions return `undefined`; B3 display uses `formatStatistic`. diff --git a/packages/core/src/glossary/terms.ts b/packages/core/src/glossary/terms.ts index 83b4ffa3b..6ffb6dca6 100644 --- a/packages/core/src/glossary/terms.ts +++ b/packages/core/src/glossary/terms.ts @@ -535,6 +535,68 @@ export const glossaryTerms: GlossaryTerm[] = [ }, // Investigation (additions) + // — Multi-level SCOUT V1 (added 2026-04-30) — + { + id: 'timelineWindow', + label: 'Timeline Window', + definition: + 'The explicit temporal scope of an investigation or hub-time view — a tagged variant of fixed, rolling, open-ended, or cumulative.', + description: + 'A timeline window travels with every chart, every Finding, and every drift comparison so the analysis answers "when does this happen?" precisely. Investigation-time defaults to open-ended; hub-time defaults to rolling matched to the hub cadence; baseline reads default to cumulative.', + category: 'methodology', + relatedTerms: ['investigationTime', 'hubTime', 'findingDrift'], + }, + { + id: 'investigationTime', + label: 'Investigation-Time', + definition: + 'The temporal scope of a single SCOUT investigation. Defaults to open-ended so the investigation grows as new data arrives.', + description: + 'Investigation-time is per-investigation: the window that the analyst chose for this issue. Distinct from hub-time, which is the cadence-matched rolling view on the Process Hub Capability tab.', + category: 'methodology', + relatedTerms: ['timelineWindow', 'hubTime'], + }, + { + id: 'hubTime', + label: 'Hub-Time', + definition: + 'The temporal scope of a Process Hub Capability tab. Defaults to a rolling window matched to the hub review cadence.', + description: + 'Hub-time keeps the Capability tab in sync with the hub review rhythm: weekly hubs read the last 7 days, monthly hubs the last 30. Distinct from investigation-time, which travels with a specific investigation.', + category: 'methodology', + relatedTerms: ['timelineWindow', 'investigationTime'], + }, + { + id: 'outputRate', + label: 'Output Rate', + definition: + 'Outputs per unit time over the active timeline window. The basic L2 (flow) reading metric in multi-level SCOUT V1.', + description: + 'Computed by computeOutputRate in @variscout/core/throughput. Cycle time, FPY, RTY arrive in subsequent slices; OEE, takt, lead time, and WIP arrive in the third.', + category: 'methodology', + relatedTerms: ['bottleneck', 'timelineWindow'], + }, + { + id: 'bottleneck', + label: 'Bottleneck', + definition: + 'The slowest step in a multi-node process — the step with the lowest output rate over the active timeline window.', + description: + 'Computed by computeBottleneck in @variscout/core/throughput. Requires multi-node scope (b2). Bottleneck identification is a structural-absence read: it does not aggregate Cpks, it points at the limiting step.', + category: 'methodology', + relatedTerms: ['outputRate', 'timelineWindow'], + }, + { + id: 'findingDrift', + label: 'Finding Drift', + definition: + 'The relative change between a Finding’s saved window context and the current window context. Default threshold 0.20.', + description: + 'When drift exceeds the threshold, the Finding footer flags it and CoScout offers to re-run the analysis on the current window. Computed by computeFindingWindowDrift in @variscout/core/findings/drift.', + category: 'methodology', + relatedTerms: ['timelineWindow', 'finding'], + }, + { id: 'question', label: 'Question', diff --git a/packages/hooks/CLAUDE.md b/packages/hooks/CLAUDE.md index 7b9e18e29..9be840e52 100644 --- a/packages/hooks/CLAUDE.md +++ b/packages/hooks/CLAUDE.md @@ -14,6 +14,7 @@ - Hooks that compose shared state (e.g., `useHubComputations`, `useCoScoutProps`) read from `@variscout/stores` selectors, never call `getState()` in render paths. - `useChartCopy` owns chart export (PNG/SVG/clipboard) dimensions and pixel ratio. Consumers pass a ref. - `usePopoutChannel` is the canonical BroadcastChannel wrapper. Findings, Improvement, Evidence Map pop-outs all use it. +- `useTimelineWindow` owns the dashboard's active TimelineWindow; `useDataRouter` is a sanity-check wrapper around the strategy's `dataRouter` (no runtime hook switching). - Flaky test watch: `packages/hooks/src/__tests__/index.test.ts` can timeout under concurrent Turbo load; passes when run alone. ## Test command diff --git a/packages/ui/CLAUDE.md b/packages/ui/CLAUDE.md index c531e62d7..26d5d7f6a 100644 --- a/packages/ui/CLAUDE.md +++ b/packages/ui/CLAUDE.md @@ -13,6 +13,7 @@ - Naming: `*Base` = shared primitive in @variscout/ui (props-based, no app logic). `*WrapperBase` = app-level composition (combines hooks + Base + app UI). App wrappers in apps/*/ import `*WrapperBase`or`\*Base` and add ~50 lines of app-specific wiring. - @variscout/ui MAY import from @variscout/stores for store-aware tab content components (`StatsTabContent`, `QuestionsTabContent`, `JournalTabContent`). This is a documented exception per ADR-056. Props-based components remain preferred for purely presentational UI. - PI Panel tabs config via `PIPanelBase` (PITabConfig API). Store-aware tab content is the default. +- `TimelineWindowPicker` lives in the `DashboardLayoutBase` chrome (above the chart grid), not in `FilterContextBar`. Slot ownership: chrome above grid = window; FilterContextBar = per-chart filter summary. - Error service (`errorService`) and hooks (`useIsMobile`, `useTheme`, `useGlossary`, `BREAKPOINTS`) are also exported from @variscout/ui. ## Test command From 17cae9d102c4df3ce0e826e85e6f3329a970f8dd Mon Sep 17 00:00:00 2001 From: Jukka-Matti Turtiainen Date: Thu, 30 Apr 2026 09:16:56 +0200 Subject: [PATCH 20/20] fix(core): replace .toFixed in defect formatMetricValue per hard rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-merge code review on PR #109 caught a hard-rule violation in the defect strategy registration. packages/core/CLAUDE.md forbids .toFixed on exported stat values; consumers should call formatStatistic from @variscout/core/i18n. The line itself predates this PR (commit 69eca5ad4) but the PR added dataRouter to the same strategy block, so folding the fix in pre-merge per feedback_bundle_followups_pre_merge.md. Replaces v.toFixed(1) with Math.round(v * 10) / 10 — same numeric output, no toFixed. Mirrors the yamazumi strategy's Math.round(v * 100) pattern. Co-Authored-By: ruflo --- packages/core/src/analysisStrategy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/analysisStrategy.ts b/packages/core/src/analysisStrategy.ts index 0835d49c8..443400ba1 100644 --- a/packages/core/src/analysisStrategy.ts +++ b/packages/core/src/analysisStrategy.ts @@ -194,7 +194,7 @@ const strategies: Readonly> = { reportTitle: 'Defect Analysis', reportSections: ['current-condition', 'drivers', 'evidence-trail', 'learning-loop'], metricLabel: () => 'Defect Rate', - formatMetricValue: (v: number) => (Number.isFinite(v) ? `${v.toFixed(1)}` : '--'), + formatMetricValue: (v: number) => (Number.isFinite(v) ? `${Math.round(v * 10) / 10}` : '--'), aiChartInsightKeys: ['ichart', 'boxplot', 'pareto'], aiToolSet: 'standard', questionStrategy: {