@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.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/core/index.d.ts +1029 -0
- package/dist/core/index.js +1553 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/process-worker.js +462 -0
- package/dist/core/process-worker.js.map +1 -0
- package/dist/index.d.ts +1153 -0
- package/dist/index.js +3146 -0
- package/dist/index.js.map +1 -0
- package/dist/test/index.d.ts +196 -0
- package/dist/test/index.js +527 -0
- package/dist/test/index.js.map +1 -0
- package/package.json +80 -0
- package/src/abstraction-controls/abstraction-controls-fixtures.ts +86 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +188 -0
- package/src/abstraction-controls/abstraction-controls.test.tsx +226 -0
- package/src/abstraction-controls/abstraction-controls.tsx +288 -0
- package/src/abstraction-controls/auto-abstraction.test.ts +196 -0
- package/src/abstraction-controls/auto-abstraction.ts +128 -0
- package/src/abstraction-controls/index.ts +4 -0
- package/src/core/abstract-graph.test.ts +209 -0
- package/src/core/abstract-graph.ts +407 -0
- package/src/core/adapters/csv.test.ts +131 -0
- package/src/core/adapters/csv.ts +146 -0
- package/src/core/adapters/flat.test.ts +149 -0
- package/src/core/adapters/flat.ts +168 -0
- package/src/core/aggregate-performance.test.ts +208 -0
- package/src/core/aggregate-performance.ts +200 -0
- package/src/core/detect-rework.test.ts +134 -0
- package/src/core/detect-rework.ts +100 -0
- package/src/core/discover-graph.test.ts +378 -0
- package/src/core/discover-graph.ts +202 -0
- package/src/core/duration-stats.test.ts +116 -0
- package/src/core/duration-stats.ts +162 -0
- package/src/core/event-log.test.ts +224 -0
- package/src/core/event-log.ts +244 -0
- package/src/core/extract-variants.test.ts +126 -0
- package/src/core/extract-variants.ts +140 -0
- package/src/core/filter-log.test.ts +193 -0
- package/src/core/filter-log.ts +215 -0
- package/src/core/fixtures/generate-bpi-2012-subset.test.ts +50 -0
- package/src/core/fixtures/generate-bpi-2012-subset.ts +216 -0
- package/src/core/fixtures/generate-bpi-2012-subset.write.ts +40 -0
- package/src/core/fixtures/order-to-cash-small.json +200 -0
- package/src/core/fixtures/synthetic-log.test.ts +109 -0
- package/src/core/fixtures/synthetic-log.ts +167 -0
- package/src/core/index.ts +118 -0
- package/src/core/reconcile-graph.test.ts +175 -0
- package/src/core/reconcile-graph.ts +107 -0
- package/src/core/scale.test.ts +80 -0
- package/src/core/scale.ts +100 -0
- package/src/core/types.ts +151 -0
- package/src/core/worker/create-process-worker.test.ts +255 -0
- package/src/core/worker/create-process-worker.ts +211 -0
- package/src/core/worker/process-worker.ts +80 -0
- package/src/index.ts +29 -0
- package/src/metric-layer-switch/index.ts +6 -0
- package/src/metric-layer-switch/metric-layer-switch.stories.tsx +131 -0
- package/src/metric-layer-switch/metric-layer-switch.test.tsx +102 -0
- package/src/metric-layer-switch/metric-layer-switch.tsx +276 -0
- package/src/process-explorer.stories.tsx +392 -0
- package/src/process-kpi-strip/index.ts +6 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +128 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +106 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +237 -0
- package/src/process-map/index.ts +13 -0
- package/src/process-map/map-model.test.ts +326 -0
- package/src/process-map/map-model.ts +873 -0
- package/src/process-map/process-activity-node.tsx +200 -0
- package/src/process-map/process-map-context.ts +71 -0
- package/src/process-map/process-map.stories.tsx +673 -0
- package/src/process-map/process-map.test.tsx +523 -0
- package/src/process-map/process-map.tsx +979 -0
- package/src/process-map/process-transition-edge.test.tsx +160 -0
- package/src/process-map/process-transition-edge.tsx +151 -0
- package/src/process-map/use-process-layout.test.tsx +265 -0
- package/src/process-map/use-process-layout.ts +315 -0
- package/src/test/contract.test.ts +99 -0
- package/src/test/contract.ts +118 -0
- package/src/test/doubles.test.tsx +51 -0
- package/src/test/doubles.tsx +82 -0
- package/src/test/index.ts +34 -0
- package/src/test/primitives.tsx +35 -0
- package/src/use-process-explorer/index.ts +8 -0
- package/src/use-process-explorer/use-process-explorer.test.ts +564 -0
- 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
|
+
}
|