@elabs-ai/components-process 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/core/index.d.ts +1029 -0
  4. package/dist/core/index.js +1553 -0
  5. package/dist/core/index.js.map +1 -0
  6. package/dist/core/process-worker.js +462 -0
  7. package/dist/core/process-worker.js.map +1 -0
  8. package/dist/index.d.ts +1153 -0
  9. package/dist/index.js +3146 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/test/index.d.ts +196 -0
  12. package/dist/test/index.js +527 -0
  13. package/dist/test/index.js.map +1 -0
  14. package/package.json +80 -0
  15. package/src/abstraction-controls/abstraction-controls-fixtures.ts +86 -0
  16. package/src/abstraction-controls/abstraction-controls.stories.tsx +188 -0
  17. package/src/abstraction-controls/abstraction-controls.test.tsx +226 -0
  18. package/src/abstraction-controls/abstraction-controls.tsx +288 -0
  19. package/src/abstraction-controls/auto-abstraction.test.ts +196 -0
  20. package/src/abstraction-controls/auto-abstraction.ts +128 -0
  21. package/src/abstraction-controls/index.ts +4 -0
  22. package/src/core/abstract-graph.test.ts +209 -0
  23. package/src/core/abstract-graph.ts +407 -0
  24. package/src/core/adapters/csv.test.ts +131 -0
  25. package/src/core/adapters/csv.ts +146 -0
  26. package/src/core/adapters/flat.test.ts +149 -0
  27. package/src/core/adapters/flat.ts +168 -0
  28. package/src/core/aggregate-performance.test.ts +208 -0
  29. package/src/core/aggregate-performance.ts +200 -0
  30. package/src/core/detect-rework.test.ts +134 -0
  31. package/src/core/detect-rework.ts +100 -0
  32. package/src/core/discover-graph.test.ts +378 -0
  33. package/src/core/discover-graph.ts +202 -0
  34. package/src/core/duration-stats.test.ts +116 -0
  35. package/src/core/duration-stats.ts +162 -0
  36. package/src/core/event-log.test.ts +224 -0
  37. package/src/core/event-log.ts +244 -0
  38. package/src/core/extract-variants.test.ts +126 -0
  39. package/src/core/extract-variants.ts +140 -0
  40. package/src/core/filter-log.test.ts +193 -0
  41. package/src/core/filter-log.ts +215 -0
  42. package/src/core/fixtures/generate-bpi-2012-subset.test.ts +50 -0
  43. package/src/core/fixtures/generate-bpi-2012-subset.ts +216 -0
  44. package/src/core/fixtures/generate-bpi-2012-subset.write.ts +40 -0
  45. package/src/core/fixtures/order-to-cash-small.json +200 -0
  46. package/src/core/fixtures/synthetic-log.test.ts +109 -0
  47. package/src/core/fixtures/synthetic-log.ts +167 -0
  48. package/src/core/index.ts +118 -0
  49. package/src/core/reconcile-graph.test.ts +175 -0
  50. package/src/core/reconcile-graph.ts +107 -0
  51. package/src/core/scale.test.ts +80 -0
  52. package/src/core/scale.ts +100 -0
  53. package/src/core/types.ts +151 -0
  54. package/src/core/worker/create-process-worker.test.ts +255 -0
  55. package/src/core/worker/create-process-worker.ts +211 -0
  56. package/src/core/worker/process-worker.ts +80 -0
  57. package/src/index.ts +29 -0
  58. package/src/metric-layer-switch/index.ts +6 -0
  59. package/src/metric-layer-switch/metric-layer-switch.stories.tsx +131 -0
  60. package/src/metric-layer-switch/metric-layer-switch.test.tsx +102 -0
  61. package/src/metric-layer-switch/metric-layer-switch.tsx +276 -0
  62. package/src/process-explorer.stories.tsx +392 -0
  63. package/src/process-kpi-strip/index.ts +6 -0
  64. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +128 -0
  65. package/src/process-kpi-strip/process-kpi-strip.test.tsx +106 -0
  66. package/src/process-kpi-strip/process-kpi-strip.tsx +237 -0
  67. package/src/process-map/index.ts +13 -0
  68. package/src/process-map/map-model.test.ts +326 -0
  69. package/src/process-map/map-model.ts +873 -0
  70. package/src/process-map/process-activity-node.tsx +200 -0
  71. package/src/process-map/process-map-context.ts +71 -0
  72. package/src/process-map/process-map.stories.tsx +673 -0
  73. package/src/process-map/process-map.test.tsx +523 -0
  74. package/src/process-map/process-map.tsx +979 -0
  75. package/src/process-map/process-transition-edge.test.tsx +160 -0
  76. package/src/process-map/process-transition-edge.tsx +151 -0
  77. package/src/process-map/use-process-layout.test.tsx +265 -0
  78. package/src/process-map/use-process-layout.ts +315 -0
  79. package/src/test/contract.test.ts +99 -0
  80. package/src/test/contract.ts +118 -0
  81. package/src/test/doubles.test.tsx +51 -0
  82. package/src/test/doubles.tsx +82 -0
  83. package/src/test/index.ts +34 -0
  84. package/src/test/primitives.tsx +35 -0
  85. package/src/use-process-explorer/index.ts +8 -0
  86. package/src/use-process-explorer/use-process-explorer.test.ts +564 -0
  87. package/src/use-process-explorer/use-process-explorer.ts +540 -0
@@ -0,0 +1,540 @@
1
+ "use client";
2
+
3
+ /**
4
+ * `useProcessExplorer` — the coordinating hook every process-mining view is driven from
5
+ * (RM-052, issue #227).
6
+ *
7
+ * This is the tri-state contract analysis §5.3 calls "the most important [API decision]
8
+ * ... because it is what makes the set usable both in a standalone prototype and embedded
9
+ * in a BI platform's mashup": `ProcessMap` and the filter menu it exposes never call
10
+ * `filterLog`/`discoverGraph` themselves — they render `selection` and emit
11
+ * `onFilterIntent`/`onSelect`, and THIS hook is what turns those intents into a
12
+ * recomputed graph. A host with its own associative-selection engine swaps this hook out
13
+ * entirely and drives the exact same components (R22, RM-058).
14
+ *
15
+ * ## What recomputes, and when
16
+ *
17
+ * - `filteredLog` — `filterLog(log, intents)`, always synchronous (a linear scan; nothing
18
+ * here is expensive enough to move off-thread).
19
+ * - **Two independent discoveries when they genuinely differ, one when they don't**
20
+ * (RM-052 round 2, #227, Invariant F — filtering re-inks, it never removes): the FULL
21
+ * `log` and the `filteredLog` are each discovered on their own sync-or-worker path with
22
+ * their own `loading` flag and request-id ref, because either one can independently
23
+ * cross `workerThreshold`. With no intent active `filteredLog === log`, and the
24
+ * filtered role REUSES the full discovery instead of recomputing it — running two
25
+ * identical discoveries in that state was a round-2 regression against `4a1a244`, fixed
26
+ * in round 3 (G1); see `useLogDiscovery`'s own docblock for the skip mechanism.
27
+ * - `graph` (the PUBLIC field) — abstraction runs on the FULL graph FIRST
28
+ * (`abstractGraph(fullGraph, abstraction)`), and the FILTERED graph is reconciled onto that
29
+ * result SECOND (`reconcileGraph`). This order is load-bearing, not incidental: reversing
30
+ * it would let a filter-ghosted element (zeroed to look unused) be mistaken by
31
+ * `abstractGraph`'s own least-frequent heuristic for a genuinely rare one and hidden by
32
+ * abstraction instead of merely dimmed by the filter. So a reader always sees the SAME
33
+ * node set regardless of which intents are active — filtering re-inks elements as
34
+ * `"excluded"` (via `selectionStates`), it never shrinks the rendered graph. Abstraction is
35
+ * still the only thing that removes a node from the render (RM-050's "sliders never change
36
+ * statistics" property, now paired with "intents never change the node set either").
37
+ * - `variants` stays sourced from the FILTERED log alone (unlike `graph`, it narrows rather
38
+ * than ghosts — a variant list has no per-row "excluded but still there" concept to draw).
39
+ * - `kpis`/`rework` — derived from `filteredLog` directly (case count, event count, variant
40
+ * count, `durationStats` over each case's own throughput time, `detectRework`), NOT from the
41
+ * abstracted/reconciled `graph` and NOT from either `useLogDiscovery` instance (#347) — all
42
+ * five figures are synchronous derivations of `filteredLog`, so they always agree with one
43
+ * another and never lag behind a still-in-flight discovery. Abstraction and filtering both
44
+ * change what is DRAWN, never what the KPI strip reports for the cases actually in scope.
45
+ *
46
+ * ## Race safety
47
+ *
48
+ * Each of the two discoveries above owns its OWN request-id ref — a worker request in flight
49
+ * for the full log and one in flight for the filtered log are superseded independently the
50
+ * moment their respective input changes again, so a slow full-log import can never clobber a
51
+ * fresher filtered-log result (or vice versa).
52
+ */
53
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
54
+ import {
55
+ abstractGraph,
56
+ type AbstractedGraph,
57
+ type AbstractionOptions,
58
+ } from "../core/abstract-graph";
59
+ import { asNormalizedLog, type NormalizedLog } from "../core/event-log";
60
+ import { detectRework, type ReworkStats } from "../core/detect-rework";
61
+ import { discoverGraph } from "../core/discover-graph";
62
+ import { durationStats } from "../core/duration-stats";
63
+ import { extractVariants, variantKey } from "../core/extract-variants";
64
+ import type { FilterSpec } from "../core/filter-log";
65
+ import { filterLog } from "../core/filter-log";
66
+ import { reconcileGraph } from "../core";
67
+ import type { EventLog, FrequencyMode, PerformanceAgg, ProcessGraph, Variant } from "../core/types";
68
+ import {
69
+ createProcessWorker,
70
+ type CreateProcessWorkerOptions,
71
+ } from "../core/worker/create-process-worker";
72
+ import type { MetricLayer } from "../metric-layer-switch";
73
+ import type {
74
+ ProcessFilterIntent,
75
+ ProcessSelection,
76
+ ProcessSelectionStates,
77
+ } from "../process-map/map-model";
78
+
79
+ /** What a node or an edge is painted with — the same domain `ProcessMap`'s metric reads. */
80
+ export type ProcessExplorerMetric = FrequencyMode | PerformanceAgg;
81
+
82
+ /** The two metric slots a process explorer coordinates. */
83
+ export interface ProcessExplorerMetricSpec {
84
+ node: ProcessExplorerMetric;
85
+ edge: ProcessExplorerMetric;
86
+ }
87
+
88
+ /**
89
+ * Every intent this hook's `applyIntent` accepts — wider than `ProcessMap`'s own menu.
90
+ *
91
+ * `ProcessFilterIntent` (RM-051) is the four kinds `ProcessMap`'s filter-intent menu
92
+ * offers (`with`/`without`/`startsWith`/`endsWith`) and stays scoped to exactly that menu.
93
+ * This hook's `FilterIntent` widens it with `{ kind: "variant" }` (RM-052 round 2, #227) so
94
+ * a variant-explorer view can drive the SAME `applyIntent`/`intents` pair to filter by a
95
+ * variant selection — a case a click on the process map can never produce, but a click on a
96
+ * variant row can. The widening is local to this hook's own type alias; it does not touch
97
+ * `ProcessFilterIntent` or `ProcessMap`'s menu, which still only ever emits the original four.
98
+ */
99
+ export type FilterIntent = ProcessFilterIntent | Extract<FilterSpec, { kind: "variant" }>;
100
+
101
+ /** Options for {@link useProcessExplorer}. */
102
+ export interface ProcessExplorerOptions {
103
+ /** Initial abstraction sliders. Default: `{ activities: 1, paths: 1 }` — the identity. */
104
+ abstraction?: Partial<AbstractionOptions>;
105
+ /** Initial metric choice. Default: `{ node: "absolute", edge: "absolute" }`. */
106
+ metric?: Partial<ProcessExplorerMetricSpec>;
107
+ /** Initial metric layer — feed straight into `MetricLayerSwitch`'s `layer` prop. Default `"frequency"`. */
108
+ layer?: MetricLayer;
109
+ /**
110
+ * Event count above which discovery and variant extraction move to a worker (RM-050's
111
+ * own stated figure). Default `50_000`. Lower it in a test that wants to exercise the
112
+ * worker path without a 50k-row fixture.
113
+ */
114
+ workerThreshold?: number;
115
+ /**
116
+ * Forwarded verbatim to `createProcessWorker` — override `forceInline`/`createWorker`
117
+ * for a test or a host with its own worker construction. The handle is created lazily,
118
+ * on the first request that crosses `workerThreshold`, so passing this costs nothing in
119
+ * a session that never does.
120
+ */
121
+ worker?: CreateProcessWorkerOptions;
122
+ }
123
+
124
+ /** What {@link useProcessExplorer} returns. See the module docblock for the recompute rules. */
125
+ export interface UseProcessExplorerResult {
126
+ /** The graph to render — already abstracted. Superset of `ProcessGraph`; see `hidden`. */
127
+ graph: AbstractedGraph;
128
+ /** Variants of the FILTERED (not abstracted — variants have no node/edge concept) log. */
129
+ variants: Variant[];
130
+ abstraction: AbstractionOptions;
131
+ setAbstraction(next: Partial<AbstractionOptions>): void;
132
+ metric: ProcessExplorerMetricSpec;
133
+ setMetric(next: Partial<ProcessExplorerMetricSpec>): void;
134
+ /** The single explicit selection — `ProcessMap`'s own `selection` prop shape. */
135
+ selection: ProcessSelection | null;
136
+ /** Pass straight through as `ProcessMap`'s `onSelect`. */
137
+ onSelect(next: ProcessSelection | null): void;
138
+ applyIntent(intent: FilterIntent): void;
139
+ clearIntent(index: number): void;
140
+ intents: FilterIntent[];
141
+ filteredLog: EventLog;
142
+ /**
143
+ * Per-element states the active filter contributes — pass straight into `ProcessMap`'s
144
+ * `selectionStates` prop (RM-052 round 2, #227, Invariant F). Every activity/transition an
145
+ * intent excluded is marked `"excluded"` here; nothing is ever removed from `graph` itself.
146
+ * `variants` (RM-052 round 3, #227, G2) marks every id named by an active
147
+ * `{ kind: "variant" }` intent `"selected"` — read by `VariantExplorer` (RM-054), not by
148
+ * `ProcessMap`, which has no variant nodes.
149
+ */
150
+ selectionStates: ProcessSelectionStates;
151
+ /** Activities/paths abstraction is currently hiding — sourced from `abstractGraph`'s own `hidden` field. */
152
+ hiddenCounts: { activities: number; paths: number };
153
+ /**
154
+ * Activities/transitions the active FILTER excluded — rendered, dimmed, never removed.
155
+ * Disjoint from `hiddenCounts` by construction: `hiddenCounts` is what abstraction removed
156
+ * from the render entirely, `excludedCounts` is what the filter re-inked but kept drawn.
157
+ */
158
+ excludedCounts: { activities: number; paths: number };
159
+ /** The active metric layer — feed straight into `MetricLayerSwitch`'s `layer` prop. */
160
+ layer: MetricLayer;
161
+ /** Pass straight through as `MetricLayerSwitch`'s `onLayerChange` — a plain setter; the
162
+ * frequency/performance metric coercion lives in `MetricLayerSwitch` itself, not here. */
163
+ setLayer(next: MetricLayer): void;
164
+ kpis: {
165
+ cases: number;
166
+ events: number;
167
+ variants: number;
168
+ /** Median case throughput time, in milliseconds. */
169
+ medianThroughput: number;
170
+ /** Fraction of cases carrying at least one repeated activity. */
171
+ reworkRate: number;
172
+ };
173
+ /** Full rework tallies — feed straight into `ProcessMap`'s `rework` prop. */
174
+ rework: ReworkStats;
175
+ /**
176
+ * `true` while a discovery/variant request for the CURRENT `filteredLog` is running off
177
+ * a worker. Always `false` when `filteredLog` stays at or under `workerThreshold` — the
178
+ * synchronous path has no gap to express. See loading-states.md: this is `loading`, not
179
+ * `isStreaming` — a settled recomputation, not token-by-token output.
180
+ */
181
+ loading: boolean;
182
+ }
183
+
184
+ const DEFAULT_ABSTRACTION: AbstractionOptions = {
185
+ activities: 1,
186
+ paths: 1,
187
+ invert: false,
188
+ keepConnected: true,
189
+ };
190
+
191
+ const DEFAULT_METRIC: ProcessExplorerMetricSpec = { node: "absolute", edge: "absolute" };
192
+
193
+ /** RM-050's own stated figure for when discovery moves off-thread. */
194
+ const DEFAULT_WORKER_THRESHOLD = 50_000;
195
+
196
+ const EMPTY_GRAPH: ProcessGraph = {
197
+ activities: [],
198
+ transitions: [],
199
+ startActivities: {},
200
+ endActivities: {},
201
+ totals: { cases: 0, events: 0, variants: 0 },
202
+ };
203
+
204
+ interface DiscoveryResult {
205
+ graph: ProcessGraph;
206
+ variants: Variant[];
207
+ }
208
+
209
+ function discoverInline(log: EventLog): DiscoveryResult {
210
+ return { graph: discoverGraph(log), variants: extractVariants(log) };
211
+ }
212
+
213
+ /**
214
+ * The KPI strip's own variant COUNT — the number of distinct activity sequences in
215
+ * `normalized`, without paying for `extractVariants`' full pipeline (a second
216
+ * normalization pass, sorting groups by frequency, hashing a `variantId` per group, and a
217
+ * `durationStats` call per group) that only a variant EXPLORER needs the shape of (PR #413
218
+ * review, P1). `kpis.variants` is a synchronous derivation of `filteredLog` alone, like
219
+ * every other KPI figure (see the module docblock), so above `workerThreshold` it must
220
+ * never re-run the exact extraction `handle.variants()` already dispatches off-thread —
221
+ * that was the main-thread block the worker path exists to avoid. `normalized` is the same
222
+ * value `kpis` already computed for `cases`/`events`/`medianThroughput`, so this adds no
223
+ * second normalization pass either.
224
+ */
225
+ function countDistinctVariants(normalized: NormalizedLog): number {
226
+ const keys = new Set<string>();
227
+ for (const kase of normalized.cases) {
228
+ keys.add(variantKey(kase.events.map((event) => event.activity)));
229
+ }
230
+ return keys.size;
231
+ }
232
+
233
+ interface DiscoveryState {
234
+ result: DiscoveryResult;
235
+ loading: boolean;
236
+ settled: boolean;
237
+ }
238
+
239
+ /**
240
+ * One log's own discovery/variants — sync inline below `workerThreshold`, off-thread above
241
+ * it, its OWN request-id ref so it can be superseded independently of any other log this
242
+ * hook is also discovering (RM-052 round 2, #227 — the full log and the filtered log each
243
+ * get one of these; see the module docblock's "Race safety").
244
+ *
245
+ * `targetLog === null` means "skip — a sibling {@link useLogDiscovery} instance already
246
+ * covers this role" (RM-052 round 3, #227, G1). It exists so the full-log and
247
+ * filtered-log derivations can share one discovery when `filteredLog === log` (no intent
248
+ * active) without calling this hook conditionally, which the Rules of Hooks forbid. A
249
+ * skipped instance runs no memo and posts no request, and its `result` stays the
250
+ * `EMPTY_GRAPH` placeholder for as long as it is skipped — but the caller must NOT read
251
+ * that placeholder as real data the moment the skip ends. `settled` (RM-052 round 4, #227,
252
+ * H1) is `false` until this instance has produced a result of its own — synchronously, in
253
+ * the same render, for a log at or under `workerThreshold`; only once the worker request
254
+ * resolves, above it — so a caller that keeps reusing the sibling discovery while
255
+ * `!settled` never shows this instance's empty placeholder as if it were a settled answer.
256
+ *
257
+ * `settled` means settled **for the log this instance is currently targeting**, never "has
258
+ * ever produced a result" (#347). The async result is stored alongside the exact log it was
259
+ * computed for; the moment `targetLog` moves on — to a different log OR to `null` (skip) —
260
+ * a stored result for the log left behind stops counting as `settled` even though the state
261
+ * update that produced it already landed. And `requestIdRef` is bumped on EVERY effect run,
262
+ * skip included: a request that was in flight when this instance transitioned into skip is
263
+ * thereby superseded on the spot, so its response — however late it lands — can never be
264
+ * stored. Without both halves, a filter cleared while its worker request is still in flight
265
+ * can leave that filter's stale, ghosted result sitting in `asyncState` where a later,
266
+ * unrelated filter's own still-unsettled round-trip would read it back out as if it were a
267
+ * real (if outdated) answer for the WRONG log — see `use-process-explorer.test.ts`'s
268
+ * "settled means settled for the CURRENT target" lock.
269
+ */
270
+ function useLogDiscovery(
271
+ targetLog: EventLog | null,
272
+ workerThreshold: number,
273
+ getHandle: () => ReturnType<typeof createProcessWorker>,
274
+ ): DiscoveryState {
275
+ const useWorkerPath = targetLog !== null && targetLog.events.length > workerThreshold;
276
+
277
+ // Synchronous path: computed directly during render, so a caller never observes a
278
+ // `loading` gap for a log that never crosses the threshold.
279
+ const syncResult = useMemo<DiscoveryResult | null>(
280
+ () => (targetLog === null || useWorkerPath ? null : discoverInline(targetLog)),
281
+ [targetLog, useWorkerPath],
282
+ );
283
+
284
+ // Tied to the exact `EventLog` it was computed for (#347) — never read back for a
285
+ // DIFFERENT `targetLog`, whether that is a new log or `null` (skip). Reference equality
286
+ // is enough: `targetLog` only ever changes when the caller hands this instance a new
287
+ // memoized reference (a fresh `filterLog(...)` result, or `log` itself).
288
+ const [asyncState, setAsyncState] = useState<{ log: EventLog; result: DiscoveryResult } | null>(
289
+ null,
290
+ );
291
+ const [loading, setLoading] = useState(false);
292
+ const requestIdRef = useRef(0);
293
+
294
+ useEffect(() => {
295
+ // Bumped on EVERY run, skip included (#347): a request already in flight when this
296
+ // instance transitions into skip (or moves on to a different `targetLog`) is thereby
297
+ // superseded on the spot, so its response — however late it lands — fails the
298
+ // `requestIdRef.current !== requestId` check below and is never stored.
299
+ const requestId = (requestIdRef.current += 1);
300
+ if (targetLog === null || !useWorkerPath) {
301
+ // Skipped, or superseded by (or never needed) the async path — never leave a stale
302
+ // `true` behind from a request that crossed the threshold before this one didn't,
303
+ // or from a filtered request the caller stopped needing when the filter cleared.
304
+ setLoading(false);
305
+ return;
306
+ }
307
+ setLoading(true);
308
+ const handle = getHandle();
309
+ Promise.all([handle.discover(targetLog), handle.variants(targetLog)])
310
+ .then(([graph, variants]) => {
311
+ if (requestIdRef.current !== requestId) return; // a later request already answered
312
+ setAsyncState({ log: targetLog, result: { graph, variants } });
313
+ setLoading(false);
314
+ })
315
+ .catch(() => {
316
+ if (requestIdRef.current !== requestId) return;
317
+ // Degrade to the inline computation rather than getting stuck loading forever —
318
+ // `createProcessWorker` already degrades internally; this catch is the belt for
319
+ // an error the handle itself could not absorb (e.g. a `variants` call after a
320
+ // `terminate()` this hook did not initiate).
321
+ setAsyncState({ log: targetLog, result: discoverInline(targetLog) });
322
+ setLoading(false);
323
+ });
324
+ // `getHandle` is intentionally excluded — it is a stable ref-backed accessor, not
325
+ // reactive state; including it would re-run this effect on every render.
326
+ }, [targetLog, useWorkerPath]);
327
+
328
+ // A result stored for a log this instance is no longer targeting reads as absent, not as
329
+ // a stale answer (#347) — this is what makes `settled` mean "settled for the CURRENT
330
+ // target" rather than "has ever produced a result".
331
+ const asyncResult =
332
+ asyncState !== null && asyncState.log === targetLog ? asyncState.result : null;
333
+
334
+ // A worker-path target this instance has no async result FOR YET is loading, whether or
335
+ // not the passive effect above has had a chance to run (PR #413 review, P2): `targetLog`
336
+ // changes synchronously in render, but the `loading` STATE variable stays at whatever the
337
+ // PREVIOUS target left it — often `false`, once settled — until the effect's
338
+ // `setLoading(true)` commits. Without this, the render that first carries a NEW
339
+ // above-threshold target reports `loading: false` alongside the `EMPTY_GRAPH` placeholder
340
+ // (`asyncResult` above is `null` because `asyncState` still names the old target), which a
341
+ // consumer reads as a settled, genuinely empty answer for one paint. Deriving the extra bit
342
+ // from `asyncState`/`targetLog` themselves needs no effect round trip, so this render
343
+ // already reports `loading: true`. Always `false` while skipped (`useWorkerPath` requires
344
+ // `targetLog !== null`) and a no-op once `asyncState` catches up to `targetLog`.
345
+ const targetUnsettled = useWorkerPath && (asyncState === null || asyncState.log !== targetLog);
346
+
347
+ return {
348
+ result: syncResult ?? asyncResult ?? { graph: EMPTY_GRAPH, variants: [] },
349
+ loading: loading || targetUnsettled,
350
+ settled: syncResult !== null || asyncResult !== null,
351
+ };
352
+ }
353
+
354
+ /**
355
+ * Coordinate abstraction, metric choice, selection and filter intents over one event log
356
+ * into everything `ProcessMap` / `AbstractionControls` / `MetricLayerSwitch` /
357
+ * `ProcessKpiStrip` need. See the module docblock.
358
+ */
359
+ export function useProcessExplorer(
360
+ log: EventLog,
361
+ opts: ProcessExplorerOptions = {},
362
+ ): UseProcessExplorerResult {
363
+ const workerThreshold = opts.workerThreshold ?? DEFAULT_WORKER_THRESHOLD;
364
+
365
+ // The worker options a caller passes are read once per request, never used to decide
366
+ // whether to re-create the handle — the handle is a long-lived resource for the life of
367
+ // this hook, matching `createProcessWorker`'s own "construct lazily, reuse" contract.
368
+ // Both discoveries below share this one handle — `createProcessWorker`'s handle answers
369
+ // concurrent requests independently, so the full-log and filtered-log discoveries never
370
+ // block one another.
371
+ const workerOptionsRef = useRef(opts.worker);
372
+ workerOptionsRef.current = opts.worker;
373
+ const handleRef = useRef<ReturnType<typeof createProcessWorker> | null>(null);
374
+ function getHandle() {
375
+ if (handleRef.current === null) {
376
+ handleRef.current = createProcessWorker(workerOptionsRef.current);
377
+ }
378
+ return handleRef.current;
379
+ }
380
+ useEffect(
381
+ () => () => {
382
+ handleRef.current?.terminate();
383
+ },
384
+ [],
385
+ );
386
+
387
+ const [abstraction, setAbstractionState] = useState<AbstractionOptions>(() => ({
388
+ ...DEFAULT_ABSTRACTION,
389
+ ...opts.abstraction,
390
+ }));
391
+ const [metric, setMetricState] = useState<ProcessExplorerMetricSpec>(() => ({
392
+ ...DEFAULT_METRIC,
393
+ ...opts.metric,
394
+ }));
395
+ const [layer, setLayerState] = useState<MetricLayer>(opts.layer ?? "frequency");
396
+ const [selection, setSelection] = useState<ProcessSelection | null>(null);
397
+ const [intents, setIntents] = useState<FilterIntent[]>([]);
398
+
399
+ const setAbstraction = useCallback((next: Partial<AbstractionOptions>) => {
400
+ setAbstractionState((prev) => ({ ...prev, ...next }));
401
+ }, []);
402
+ const setMetric = useCallback((next: Partial<ProcessExplorerMetricSpec>) => {
403
+ setMetricState((prev) => ({ ...prev, ...next }));
404
+ }, []);
405
+ // A plain setter — the frequency/performance metric coercion that goes with a layer
406
+ // switch lives in `MetricLayerSwitch` itself (it already calls `onMetricChange` before
407
+ // `onLayerChange`), not here.
408
+ const setLayer = useCallback((next: MetricLayer) => setLayerState(next), []);
409
+ const onSelect = useCallback((next: ProcessSelection | null) => setSelection(next), []);
410
+ const applyIntent = useCallback((intent: FilterIntent) => {
411
+ setIntents((prev) => [...prev, intent]);
412
+ }, []);
413
+ const clearIntent = useCallback((index: number) => {
414
+ setIntents((prev) => prev.filter((_, i) => i !== index));
415
+ }, []);
416
+
417
+ const filteredLog = useMemo(
418
+ () => (intents.length === 0 ? log : filterLog(log, intents)),
419
+ [log, intents],
420
+ );
421
+
422
+ // Two independent discoveries — see the module docblock. `variants`, `kpis` and `rework`
423
+ // read the FILTERED one; `graph` reads BOTH, full first through abstraction, then
424
+ // reconciled against filtered (Invariant F: filtering re-inks, never removes).
425
+ //
426
+ // With no intent active, `filteredLog === log` (see `filteredLog` above), and the
427
+ // filtered role REUSES the full discovery rather than recomputing it from scratch —
428
+ // decision §1.4 step 3 / §4's whole cost argument rests on this: "the pipeline reuses
429
+ // `fullRaw` for both roles and runs exactly one discovery — identical to today" (RM-052
430
+ // round 3, #227, G1). `useLogDiscovery` cannot be called conditionally (Rules of
431
+ // Hooks), so the second instance is always called, but is told to SKIP (`null`) exactly
432
+ // when its sibling already covers the same log; its own request-id ref never fires in
433
+ // that state, so the two derivations still supersede independently the moment the logs
434
+ // genuinely diverge again.
435
+ const sameLog = filteredLog === log;
436
+ const fullDiscovery = useLogDiscovery(log, workerThreshold, getHandle);
437
+ const filteredOwnDiscovery = useLogDiscovery(
438
+ sameLog ? null : filteredLog,
439
+ workerThreshold,
440
+ getHandle,
441
+ );
442
+ // Keep reading the full discovery until the filtered instance has settled a result of
443
+ // its OWN — not just until the skip ends (RM-052 round 4, #227, H1). The moment the
444
+ // first intent makes `filteredLog !== log`, `filteredOwnDiscovery` starts running but
445
+ // has not resolved yet on a worker-path log; reading it immediately would paint its
446
+ // still-`EMPTY_GRAPH` placeholder (all-ghosted, all-zero) for the whole round-trip.
447
+ const filteredDiscovery =
448
+ sameLog || !filteredOwnDiscovery.settled ? fullDiscovery : filteredOwnDiscovery;
449
+ // `loading` ORs the two RAW instances, not the substituted `filteredDiscovery` above —
450
+ // reading `filteredDiscovery.loading` here would silently drop the real in-flight signal
451
+ // during exactly the window this fix targets: while `filteredOwnDiscovery` is unsettled,
452
+ // `filteredDiscovery` reads as `fullDiscovery` (already resolved, `loading: false`), so
453
+ // ORing its `.loading` would report `false` even though `filteredOwnDiscovery.loading` is
454
+ // genuinely `true`. `filteredOwnDiscovery.loading` is always `false` while skipped
455
+ // (`sameLog`), so this is a no-op change there.
456
+ const loading = fullDiscovery.loading || filteredOwnDiscovery.loading;
457
+
458
+ const presented = useMemo(
459
+ () => abstractGraph(fullDiscovery.result.graph, abstraction),
460
+ [fullDiscovery.result.graph, abstraction],
461
+ );
462
+
463
+ const reconciled = useMemo(
464
+ () => reconcileGraph(presented, filteredDiscovery.result.graph),
465
+ [presented, filteredDiscovery.result.graph],
466
+ );
467
+
468
+ const graph = reconciled.graph;
469
+
470
+ const selectionStates = useMemo<ProcessSelectionStates>(
471
+ () => ({
472
+ activities: Object.fromEntries(
473
+ reconciled.excludedActivities.map((id) => [id, "excluded" as const]),
474
+ ),
475
+ transitions: Object.fromEntries(
476
+ reconciled.excludedTransitions.map((key) => [key, "excluded" as const]),
477
+ ),
478
+ // Decision §1.4 step 5 (RM-052 round 3, #227, G2): there is no click channel for a
479
+ // variant, so `"selected"` here is intent-derived — every id named by an active
480
+ // `{ kind: "variant" }` intent, read by `VariantExplorer` (RM-054), not by
481
+ // `ProcessMap`. This namespace was declared on `ProcessSelectionStates` from round 2
482
+ // onward and never populated until this fix.
483
+ variants: Object.fromEntries(
484
+ intents
485
+ .flatMap((intent) => (intent.kind === "variant" ? intent.ids : []))
486
+ .map((id) => [id, "selected" as const]),
487
+ ),
488
+ }),
489
+ [reconciled, intents],
490
+ );
491
+
492
+ const excludedCounts = useMemo(
493
+ () => ({
494
+ activities: reconciled.excludedActivities.length,
495
+ paths: reconciled.excludedTransitions.length,
496
+ }),
497
+ [reconciled],
498
+ );
499
+
500
+ const rework = useMemo(() => detectRework(filteredLog), [filteredLog]);
501
+
502
+ // `variants` used to read `filteredDiscovery.result.variants.length` — the substituted,
503
+ // possibly-still-settling discovery — so it could disagree with `cases`/`events` for the
504
+ // whole window a filtered discovery was in flight (#347). It is now, like the other four
505
+ // figures, a synchronous derivation of `filteredLog` alone: all three KPIs always agree.
506
+ const kpis = useMemo(() => {
507
+ const normalized = asNormalizedLog(filteredLog);
508
+ const medianThroughput = durationStats(normalized.cases.map((kase) => kase.duration)).median;
509
+ return {
510
+ cases: normalized.totals.cases,
511
+ events: normalized.totals.events,
512
+ variants: countDistinctVariants(normalized),
513
+ medianThroughput,
514
+ reworkRate: rework.caseReworkRate,
515
+ };
516
+ }, [filteredLog, rework]);
517
+
518
+ return {
519
+ graph,
520
+ variants: filteredDiscovery.result.variants,
521
+ abstraction,
522
+ setAbstraction,
523
+ metric,
524
+ setMetric,
525
+ layer,
526
+ setLayer,
527
+ selection,
528
+ onSelect,
529
+ applyIntent,
530
+ clearIntent,
531
+ intents,
532
+ filteredLog,
533
+ selectionStates,
534
+ hiddenCounts: graph.hidden,
535
+ excludedCounts,
536
+ kpis,
537
+ rework,
538
+ loading,
539
+ };
540
+ }