@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,378 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { discoverGraph } from "./discover-graph";
|
|
4
|
+
import { DURATION_SAMPLE_CAP } from "./duration-stats";
|
|
5
|
+
import { asNormalizedLog, normalizeLog } from "./event-log";
|
|
6
|
+
import { generateSyntheticLog } from "./fixtures/synthetic-log";
|
|
7
|
+
import fixture from "./fixtures/order-to-cash-small.json";
|
|
8
|
+
import type { ActivityStats, EventLog, TransitionStats } from "./types";
|
|
9
|
+
|
|
10
|
+
const orderToCash = fixture as EventLog;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The five traces in `order-to-cash-small.json`, restated here so the expected table
|
|
14
|
+
* below can be read against them without opening the fixture:
|
|
15
|
+
*
|
|
16
|
+
* case-1 Create Order · Check Credit · Approve Order · Ship Order · Send Invoice · Receive Payment
|
|
17
|
+
* case-2 Create Order · Check Credit · Approve Order · Ship Order · Send Invoice · Receive Payment
|
|
18
|
+
* case-3 Create Order · Check Credit · Reject Order
|
|
19
|
+
* case-4 Create Order · Check Credit · Amend Order · Check Credit · Approve Order · Ship Order ·
|
|
20
|
+
* Send Invoice · Receive Payment
|
|
21
|
+
* case-5 Create Order · Check Credit · Approve Order · Send Invoice · Ship Order · Receive Payment
|
|
22
|
+
*
|
|
23
|
+
* 29 events, 5 cases, 4 distinct sequences. Every number below was counted from those
|
|
24
|
+
* five lines by hand, not read off the implementation.
|
|
25
|
+
*/
|
|
26
|
+
type ActivityRow = Omit<ActivityStats, "duration">;
|
|
27
|
+
type TransitionRow = Omit<TransitionStats, "duration">;
|
|
28
|
+
|
|
29
|
+
const stripDuration = (graph: { activities: ActivityStats[]; transitions: TransitionStats[] }) => ({
|
|
30
|
+
activities: graph.activities.map(({ duration: _duration, ...row }) => row) as ActivityRow[],
|
|
31
|
+
transitions: graph.transitions.map(({ duration: _duration, ...row }) => row) as TransitionRow[],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("discoverGraph on the 5-case order-to-cash fixture", () => {
|
|
35
|
+
it("reproduces the hand-computed activity table", () => {
|
|
36
|
+
// Ordered busiest-first, ties broken by name.
|
|
37
|
+
expect(stripDuration(discoverGraph(orderToCash)).activities).toEqual([
|
|
38
|
+
{
|
|
39
|
+
id: "Check Credit",
|
|
40
|
+
label: "Check Credit",
|
|
41
|
+
instances: 6,
|
|
42
|
+
cases: 5,
|
|
43
|
+
isStart: false,
|
|
44
|
+
isEnd: false,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "Create Order",
|
|
48
|
+
label: "Create Order",
|
|
49
|
+
instances: 5,
|
|
50
|
+
cases: 5,
|
|
51
|
+
isStart: true,
|
|
52
|
+
isEnd: false,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "Approve Order",
|
|
56
|
+
label: "Approve Order",
|
|
57
|
+
instances: 4,
|
|
58
|
+
cases: 4,
|
|
59
|
+
isStart: false,
|
|
60
|
+
isEnd: false,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "Receive Payment",
|
|
64
|
+
label: "Receive Payment",
|
|
65
|
+
instances: 4,
|
|
66
|
+
cases: 4,
|
|
67
|
+
isStart: false,
|
|
68
|
+
isEnd: true,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "Send Invoice",
|
|
72
|
+
label: "Send Invoice",
|
|
73
|
+
instances: 4,
|
|
74
|
+
cases: 4,
|
|
75
|
+
isStart: false,
|
|
76
|
+
isEnd: false,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "Ship Order",
|
|
80
|
+
label: "Ship Order",
|
|
81
|
+
instances: 4,
|
|
82
|
+
cases: 4,
|
|
83
|
+
isStart: false,
|
|
84
|
+
isEnd: false,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "Amend Order",
|
|
88
|
+
label: "Amend Order",
|
|
89
|
+
instances: 1,
|
|
90
|
+
cases: 1,
|
|
91
|
+
isStart: false,
|
|
92
|
+
isEnd: false,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "Reject Order",
|
|
96
|
+
label: "Reject Order",
|
|
97
|
+
instances: 1,
|
|
98
|
+
cases: 1,
|
|
99
|
+
isStart: false,
|
|
100
|
+
isEnd: true,
|
|
101
|
+
},
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("reproduces the hand-computed transition table", () => {
|
|
106
|
+
// Ordered by count descending, then source, then target.
|
|
107
|
+
expect(stripDuration(discoverGraph(orderToCash)).transitions).toEqual([
|
|
108
|
+
{
|
|
109
|
+
source: "Create Order",
|
|
110
|
+
target: "Check Credit",
|
|
111
|
+
count: 5,
|
|
112
|
+
caseCount: 5,
|
|
113
|
+
isSelfLoop: false,
|
|
114
|
+
isBackEdge: false,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
source: "Check Credit",
|
|
118
|
+
target: "Approve Order",
|
|
119
|
+
count: 4,
|
|
120
|
+
caseCount: 4,
|
|
121
|
+
isSelfLoop: false,
|
|
122
|
+
isBackEdge: false,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
source: "Approve Order",
|
|
126
|
+
target: "Ship Order",
|
|
127
|
+
count: 3,
|
|
128
|
+
caseCount: 3,
|
|
129
|
+
isSelfLoop: false,
|
|
130
|
+
isBackEdge: false,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
source: "Send Invoice",
|
|
134
|
+
target: "Receive Payment",
|
|
135
|
+
count: 3,
|
|
136
|
+
caseCount: 3,
|
|
137
|
+
isSelfLoop: false,
|
|
138
|
+
isBackEdge: false,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
source: "Ship Order",
|
|
142
|
+
target: "Send Invoice",
|
|
143
|
+
count: 3,
|
|
144
|
+
caseCount: 3,
|
|
145
|
+
isSelfLoop: false,
|
|
146
|
+
isBackEdge: false,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
source: "Amend Order",
|
|
150
|
+
target: "Check Credit",
|
|
151
|
+
count: 1,
|
|
152
|
+
caseCount: 1,
|
|
153
|
+
isSelfLoop: false,
|
|
154
|
+
isBackEdge: false,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
source: "Approve Order",
|
|
158
|
+
target: "Send Invoice",
|
|
159
|
+
count: 1,
|
|
160
|
+
caseCount: 1,
|
|
161
|
+
isSelfLoop: false,
|
|
162
|
+
isBackEdge: false,
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
source: "Check Credit",
|
|
166
|
+
target: "Amend Order",
|
|
167
|
+
count: 1,
|
|
168
|
+
caseCount: 1,
|
|
169
|
+
isSelfLoop: false,
|
|
170
|
+
isBackEdge: false,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
source: "Check Credit",
|
|
174
|
+
target: "Reject Order",
|
|
175
|
+
count: 1,
|
|
176
|
+
caseCount: 1,
|
|
177
|
+
isSelfLoop: false,
|
|
178
|
+
isBackEdge: false,
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
source: "Send Invoice",
|
|
182
|
+
target: "Ship Order",
|
|
183
|
+
count: 1,
|
|
184
|
+
caseCount: 1,
|
|
185
|
+
isSelfLoop: false,
|
|
186
|
+
isBackEdge: false,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
source: "Ship Order",
|
|
190
|
+
target: "Receive Payment",
|
|
191
|
+
count: 1,
|
|
192
|
+
caseCount: 1,
|
|
193
|
+
isSelfLoop: false,
|
|
194
|
+
isBackEdge: false,
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("reproduces the hand-computed start/end tallies and totals", () => {
|
|
200
|
+
const graph = discoverGraph(orderToCash);
|
|
201
|
+
expect(graph.startActivities).toEqual({ "Create Order": 5 });
|
|
202
|
+
expect(graph.endActivities).toEqual({ "Receive Payment": 4, "Reject Order": 1 });
|
|
203
|
+
expect(graph.totals).toEqual({ cases: 5, events: 29, variants: 4 });
|
|
204
|
+
// The two tallies must add up to the case count, once each.
|
|
205
|
+
const sum = (record: Record<string, number>): number =>
|
|
206
|
+
Object.values(record).reduce((total, value) => total + value, 0);
|
|
207
|
+
expect(sum(graph.startActivities)).toBe(5);
|
|
208
|
+
expect(sum(graph.endActivities)).toBe(5);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("reproduces the hand-computed idle-time statistics of the busiest edge", () => {
|
|
212
|
+
// Create Order → Check Credit: 60 min in cases 1-4, 120 min in case 5.
|
|
213
|
+
// sorted [3.6e6, 3.6e6, 3.6e6, 3.6e6, 7.2e6] → sum 21.6e6, mean 4.32e6, median 3.6e6,
|
|
214
|
+
// p90 at pos 0.9 * 4 = 3.6 → 3.6e6 + 0.6 * 3.6e6 = 5.76e6, trim floor(0.5) = 0.
|
|
215
|
+
const edge = discoverGraph(orderToCash).transitions[0];
|
|
216
|
+
expect(edge?.duration).toEqual({
|
|
217
|
+
min: 3_600_000,
|
|
218
|
+
max: 7_200_000,
|
|
219
|
+
mean: 4_320_000,
|
|
220
|
+
median: 3_600_000,
|
|
221
|
+
p90: 5_760_000,
|
|
222
|
+
sum: 21_600_000,
|
|
223
|
+
trimmedMean: 4_320_000,
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("reports zero activity durations, because every fixture event is atomic", () => {
|
|
228
|
+
for (const activity of discoverGraph(orderToCash).activities) {
|
|
229
|
+
expect(activity.duration.sum).toBe(0);
|
|
230
|
+
expect(activity.duration.max).toBe(0);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("accepts an already-normalized log and answers identically", () => {
|
|
235
|
+
expect(discoverGraph(normalizeLog(orderToCash))).toEqual(discoverGraph(orderToCash));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("is deterministic — two runs over one log are deeply equal", () => {
|
|
239
|
+
expect(discoverGraph(orderToCash)).toEqual(discoverGraph(orderToCash));
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("discoverGraph determinism past the duration-sample cap (RM-052 round 2, #227, F5)", () => {
|
|
244
|
+
// The determinism test above runs on the 5-case order-to-cash fixture (29 events), so no
|
|
245
|
+
// activity or edge ever offers more than a handful of duration samples — `DurationSampler`
|
|
246
|
+
// fills its reservoir and stops, and its seeded `mulberry32` PRNG is never consulted. That
|
|
247
|
+
// makes the assertion real but VACUOUS as a lock on the reservoir-REPLACEMENT branch (the
|
|
248
|
+
// one `add()` takes once `this.reservoir.length === this.capacity`, which is where a
|
|
249
|
+
// clock-seeded or otherwise non-deterministic RNG would actually show up as flaky output).
|
|
250
|
+
//
|
|
251
|
+
// "Create Order" opens every trace exactly once and unconditionally (see `buildTrace` in
|
|
252
|
+
// `./fixtures/synthetic-log.ts` — it is pushed before any of the random branches run), so
|
|
253
|
+
// at `cases: 5_000` its activity, and the "Create Order" → "Check Credit" edge that always
|
|
254
|
+
// follows it, each accumulate exactly 5,000 duration samples — comfortably past
|
|
255
|
+
// `DURATION_SAMPLE_CAP` (4,096) — forcing the replacement branch to run on every one of the
|
|
256
|
+
// 904 samples past the cap, not merely on the ones that fill it.
|
|
257
|
+
const bigLog = generateSyntheticLog({ cases: 5_000, seed: 11 });
|
|
258
|
+
|
|
259
|
+
it("proves the reservoir-replacement branch actually ran, by observed sample count — not log size", () => {
|
|
260
|
+
// Non-vacuity check, corrected (RM-052 round 3, #227, G3): `instances`/`count` below
|
|
261
|
+
// are the number of EVENTS/TRANSITIONS `discoverGraph` walked for that activity/edge —
|
|
262
|
+
// not a read of `DurationSampler`'s own state. They only stand in for "samples offered
|
|
263
|
+
// to the sampler" because every walked event/transition unconditionally calls
|
|
264
|
+
// `duration.add(...)` (see `discover-graph.ts`) with a FINITE duration. Assert that
|
|
265
|
+
// premise directly: every case in `bigLog`, once normalized the same way
|
|
266
|
+
// `discoverGraph` normalizes its input, has a finite `duration`/`start`/`end` on every
|
|
267
|
+
// event — so no event is silently dropped by `DurationSampler.add`'s
|
|
268
|
+
// `Number.isFinite` guard, and the instances/count tallies below really do equal the
|
|
269
|
+
// sampler's offered-sample count.
|
|
270
|
+
for (const normalizedCase of asNormalizedLog(bigLog).cases) {
|
|
271
|
+
for (const event of normalizedCase.events) {
|
|
272
|
+
expect(Number.isFinite(event.duration)).toBe(true);
|
|
273
|
+
expect(Number.isFinite(event.start)).toBe(true);
|
|
274
|
+
expect(Number.isFinite(event.end)).toBe(true);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const graph = discoverGraph(bigLog);
|
|
279
|
+
const createOrder = graph.activities.find((activity) => activity.id === "Create Order");
|
|
280
|
+
expect(createOrder?.instances).toBeGreaterThan(DURATION_SAMPLE_CAP);
|
|
281
|
+
|
|
282
|
+
const firstEdge = graph.transitions.find(
|
|
283
|
+
(edge) => edge.source === "Create Order" && edge.target === "Check Credit",
|
|
284
|
+
);
|
|
285
|
+
expect(firstEdge?.count).toBeGreaterThan(DURATION_SAMPLE_CAP);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("is deterministic even once the PRNG reservoir-replacement branch is exercised", () => {
|
|
289
|
+
// Two independent `discoverGraph` calls over the same log, each building its own fresh
|
|
290
|
+
// `DurationSampler`s from scratch (seeded from a per-graph creation counter, never from
|
|
291
|
+
// a clock — see the `nextSeed` comment in `discover-graph.ts`). If that seeding, or the
|
|
292
|
+
// PRNG it feeds, ever became non-deterministic, this is where it would show up: past the
|
|
293
|
+
// cap, every kept/discarded decision depends on `this.random()`, so a diverging seed
|
|
294
|
+
// would diverge the retained reservoir, and with it `median`/`p90`/`trimmedMean`.
|
|
295
|
+
expect(discoverGraph(bigLog)).toEqual(discoverGraph(bigLog));
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("discoverGraph edge semantics", () => {
|
|
300
|
+
it("marks a repeated activity as a self-loop", () => {
|
|
301
|
+
const log: EventLog = {
|
|
302
|
+
events: [
|
|
303
|
+
{ caseId: "c1", activity: "Pick", timestamp: 0 },
|
|
304
|
+
{ caseId: "c1", activity: "Pick", timestamp: 1000 },
|
|
305
|
+
],
|
|
306
|
+
};
|
|
307
|
+
const [edge] = discoverGraph(log).transitions;
|
|
308
|
+
expect(edge).toMatchObject({ source: "Pick", target: "Pick", count: 1, isSelfLoop: true });
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("never sets isBackEdge — discovery does not lay out", () => {
|
|
312
|
+
const graph = discoverGraph(generateSyntheticLog({ cases: 200, seed: 3 }));
|
|
313
|
+
expect(graph.transitions.every((edge) => edge.isBackEdge === false)).toBe(true);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("counts a repeated edge once per case in caseCount but every time in count", () => {
|
|
317
|
+
const log: EventLog = {
|
|
318
|
+
events: [
|
|
319
|
+
{ caseId: "c1", activity: "A", timestamp: 0 },
|
|
320
|
+
{ caseId: "c1", activity: "B", timestamp: 1 },
|
|
321
|
+
{ caseId: "c1", activity: "A", timestamp: 2 },
|
|
322
|
+
{ caseId: "c1", activity: "B", timestamp: 3 },
|
|
323
|
+
],
|
|
324
|
+
};
|
|
325
|
+
const edge = discoverGraph(log).transitions.find((e) => e.source === "A" && e.target === "B");
|
|
326
|
+
expect(edge).toMatchObject({ count: 2, caseCount: 1 });
|
|
327
|
+
const activity = discoverGraph(log).activities.find((a) => a.id === "A");
|
|
328
|
+
expect(activity).toMatchObject({ instances: 2, cases: 1 });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("measures idle time by default and inter-start time on request", () => {
|
|
332
|
+
const log: EventLog = {
|
|
333
|
+
events: [
|
|
334
|
+
{ caseId: "c1", activity: "A", startTimestamp: 0, timestamp: 1000 },
|
|
335
|
+
{ caseId: "c1", activity: "B", startTimestamp: 5000, timestamp: 6000 },
|
|
336
|
+
],
|
|
337
|
+
};
|
|
338
|
+
expect(discoverGraph(log).transitions[0]?.duration.sum).toBe(4000); // 5000 - 1000
|
|
339
|
+
expect(discoverGraph(log, { flowTime: "inter_start_time" }).transitions[0]?.duration.sum).toBe(
|
|
340
|
+
5000, // 5000 - 0
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("answers an empty log with an empty graph", () => {
|
|
345
|
+
expect(discoverGraph({ events: [] })).toEqual({
|
|
346
|
+
activities: [],
|
|
347
|
+
transitions: [],
|
|
348
|
+
startActivities: {},
|
|
349
|
+
endActivities: {},
|
|
350
|
+
totals: { cases: 0, events: 0, variants: 0 },
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("gives a single-event case a start, an end and no transition", () => {
|
|
355
|
+
const graph = discoverGraph({ events: [{ caseId: "c1", activity: "Only", timestamp: 0 }] });
|
|
356
|
+
expect(graph.transitions).toEqual([]);
|
|
357
|
+
expect(graph.activities[0]).toMatchObject({ id: "Only", isStart: true, isEnd: true });
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe("discoverGraph performance", () => {
|
|
362
|
+
it("discovers a 13 000-case synthetic log well inside the budget", () => {
|
|
363
|
+
const log = generateSyntheticLog({ cases: 13_000, seed: 1 });
|
|
364
|
+
// Warm the JIT so the measurement is of steady-state work, not of first-call
|
|
365
|
+
// compilation — otherwise the number swings by an order of magnitude on CI.
|
|
366
|
+
discoverGraph(generateSyntheticLog({ cases: 200, seed: 1 }));
|
|
367
|
+
|
|
368
|
+
const started = performance.now();
|
|
369
|
+
const graph = discoverGraph(log);
|
|
370
|
+
const elapsed = performance.now() - started;
|
|
371
|
+
|
|
372
|
+
expect(graph.totals.cases).toBe(13_000);
|
|
373
|
+
expect(graph.totals.events).toBeGreaterThan(100_000);
|
|
374
|
+
// The design budget is 300 ms; the assertion carries 5x headroom so a loaded CI
|
|
375
|
+
// runner cannot turn a real regression gate into a flaky wall-clock test.
|
|
376
|
+
expect(elapsed).toBeLessThan(1500);
|
|
377
|
+
});
|
|
378
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directly-follows graph discovery — RM-049.
|
|
3
|
+
*
|
|
4
|
+
* The directly-follows abstraction (one node per activity, one edge per adjacent pair in
|
|
5
|
+
* a trace, with frequency and performance aggregates on both) is the standard starting
|
|
6
|
+
* point of every process-mining view. The shape of what a discovered graph carries —
|
|
7
|
+
* per-activity instance and case counts, per-edge count and case count, start/end
|
|
8
|
+
* activity tallies — follows pm4js; see `ATTRIBUTION.md`. No pm4js code is copied, and
|
|
9
|
+
* nothing here derives from the AGPL Python reference implementation.
|
|
10
|
+
*
|
|
11
|
+
* Deterministic and layout-free: `isBackEdge` is always `false` here, because whether an
|
|
12
|
+
* edge points backwards is a property of a LAYOUT, not of a log. A layout pass sets it.
|
|
13
|
+
*/
|
|
14
|
+
import { DURATION_SAMPLE_CAP, DurationSampler } from "./duration-stats";
|
|
15
|
+
import { asNormalizedLog, type AnyLog, type NormalizedEvent } from "./event-log";
|
|
16
|
+
import { VARIANT_KEY_SEPARATOR } from "./extract-variants";
|
|
17
|
+
import type { ActivityStats, FlowTime, ProcessGraph, TransitionStats } from "./types";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Separator used to key an edge. The same C0 control character the variant key uses, and
|
|
21
|
+
* for the same reason: no activity name can contain it, so `source + SEP + target` is an
|
|
22
|
+
* injective key.
|
|
23
|
+
*/
|
|
24
|
+
export const EDGE_KEY_SEPARATOR = VARIANT_KEY_SEPARATOR;
|
|
25
|
+
|
|
26
|
+
/** Options for {@link discoverGraph}. Every field has a default; `discoverGraph(log)` works. */
|
|
27
|
+
export interface DiscoverGraphOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Which elapsed time an edge measures. Defaults to `"idle_time"` — the wait between the
|
|
30
|
+
* source completing and the target starting, which is the number a bottleneck view
|
|
31
|
+
* wants. For a log of atomic events the two choices coincide.
|
|
32
|
+
*/
|
|
33
|
+
flowTime?: FlowTime;
|
|
34
|
+
/**
|
|
35
|
+
* Reservoir capacity per activity and per edge. Lower it for a very wide log; raise it
|
|
36
|
+
* for sharper tail statistics. `sum`/`mean`/`min`/`max` stay exact regardless.
|
|
37
|
+
*/
|
|
38
|
+
maxDurationSamples?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ActivityAccumulator {
|
|
42
|
+
instances: number;
|
|
43
|
+
cases: number;
|
|
44
|
+
duration: DurationSampler;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface TransitionAccumulator {
|
|
48
|
+
source: string;
|
|
49
|
+
target: string;
|
|
50
|
+
count: number;
|
|
51
|
+
caseCount: number;
|
|
52
|
+
duration: DurationSampler;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Derive a {@link ProcessGraph} from a raw or already-normalized log.
|
|
57
|
+
*
|
|
58
|
+
* One pass per trace: each case's activity sequence is walked once, incrementing
|
|
59
|
+
* per-activity and per-edge counters and feeding two duration samplers. Per-case
|
|
60
|
+
* uniqueness (the `cases` and `caseCount` fields) is tracked with two `Set`s that are
|
|
61
|
+
* cleared per case rather than a per-key `Set` of case ids, so memory stays proportional
|
|
62
|
+
* to the widest trace, not to the log.
|
|
63
|
+
*
|
|
64
|
+
* `totals.variants` is counted from the same walk (the joined activity sequence goes into
|
|
65
|
+
* a `Set`), so a caller that only needs the headline number does not also have to run
|
|
66
|
+
* `extractVariants`.
|
|
67
|
+
*/
|
|
68
|
+
export function discoverGraph(log: AnyLog, options: DiscoverGraphOptions = {}): ProcessGraph {
|
|
69
|
+
const flowTime: FlowTime = options.flowTime ?? "idle_time";
|
|
70
|
+
const capacity = options.maxDurationSamples ?? DURATION_SAMPLE_CAP;
|
|
71
|
+
const normalized = asNormalizedLog(log);
|
|
72
|
+
|
|
73
|
+
const activities = new Map<string, ActivityAccumulator>();
|
|
74
|
+
const transitions = new Map<string, TransitionAccumulator>();
|
|
75
|
+
const startActivities = new Map<string, number>();
|
|
76
|
+
const endActivities = new Map<string, number>();
|
|
77
|
+
const variantKeys = new Set<string>();
|
|
78
|
+
|
|
79
|
+
// Sampler seeds come from a creation counter, never from a clock or from entropy: the
|
|
80
|
+
// iteration order over a given log is fixed, so the same log always seeds the same
|
|
81
|
+
// samplers and the reservoir retains the same rows.
|
|
82
|
+
let samplerIndex = 0;
|
|
83
|
+
const nextSeed = (): number => {
|
|
84
|
+
samplerIndex += 1;
|
|
85
|
+
return (0x9e3779b9 ^ Math.imul(samplerIndex, 0x9e3779b1)) >>> 0;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
let events = 0;
|
|
89
|
+
const seenActivities = new Set<string>();
|
|
90
|
+
const seenEdges = new Set<string>();
|
|
91
|
+
|
|
92
|
+
for (const kase of normalized.cases) {
|
|
93
|
+
const trace = kase.events;
|
|
94
|
+
if (trace.length === 0) continue;
|
|
95
|
+
events += trace.length;
|
|
96
|
+
seenActivities.clear();
|
|
97
|
+
seenEdges.clear();
|
|
98
|
+
|
|
99
|
+
const sequence = new Array<string>(trace.length);
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < trace.length; i += 1) {
|
|
102
|
+
const event = trace[i] as NormalizedEvent;
|
|
103
|
+
const name = event.activity;
|
|
104
|
+
sequence[i] = name;
|
|
105
|
+
|
|
106
|
+
let activity = activities.get(name);
|
|
107
|
+
if (activity === undefined) {
|
|
108
|
+
activity = { instances: 0, cases: 0, duration: new DurationSampler(nextSeed(), capacity) };
|
|
109
|
+
activities.set(name, activity);
|
|
110
|
+
}
|
|
111
|
+
activity.instances += 1;
|
|
112
|
+
activity.duration.add(event.duration);
|
|
113
|
+
if (!seenActivities.has(name)) {
|
|
114
|
+
seenActivities.add(name);
|
|
115
|
+
activity.cases += 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (i === 0) continue;
|
|
119
|
+
const previous = trace[i - 1] as NormalizedEvent;
|
|
120
|
+
const source = previous.activity;
|
|
121
|
+
const key = `${source}${EDGE_KEY_SEPARATOR}${name}`;
|
|
122
|
+
let edge = transitions.get(key);
|
|
123
|
+
if (edge === undefined) {
|
|
124
|
+
edge = {
|
|
125
|
+
source,
|
|
126
|
+
target: name,
|
|
127
|
+
count: 0,
|
|
128
|
+
caseCount: 0,
|
|
129
|
+
duration: new DurationSampler(nextSeed(), capacity),
|
|
130
|
+
};
|
|
131
|
+
transitions.set(key, edge);
|
|
132
|
+
}
|
|
133
|
+
edge.count += 1;
|
|
134
|
+
edge.duration.add(
|
|
135
|
+
flowTime === "inter_start_time" ? event.start - previous.start : event.start - previous.end,
|
|
136
|
+
);
|
|
137
|
+
if (!seenEdges.has(key)) {
|
|
138
|
+
seenEdges.add(key);
|
|
139
|
+
edge.caseCount += 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const first = trace[0] as NormalizedEvent;
|
|
144
|
+
const last = trace[trace.length - 1] as NormalizedEvent;
|
|
145
|
+
startActivities.set(first.activity, (startActivities.get(first.activity) ?? 0) + 1);
|
|
146
|
+
endActivities.set(last.activity, (endActivities.get(last.activity) ?? 0) + 1);
|
|
147
|
+
variantKeys.add(sequence.join(VARIANT_KEY_SEPARATOR));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const activityList: ActivityStats[] = [];
|
|
151
|
+
for (const [id, accumulator] of activities) {
|
|
152
|
+
activityList.push({
|
|
153
|
+
id,
|
|
154
|
+
label: id,
|
|
155
|
+
instances: accumulator.instances,
|
|
156
|
+
cases: accumulator.cases,
|
|
157
|
+
isStart: startActivities.has(id),
|
|
158
|
+
isEnd: endActivities.has(id),
|
|
159
|
+
duration: accumulator.duration.stats(),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// Deterministic, reader-friendly order: busiest first, ties broken by name, so the same
|
|
163
|
+
// log always yields the same array in the same positions (downstream items snapshot it).
|
|
164
|
+
activityList.sort((a, b) => b.instances - a.instances || compareStrings(a.id, b.id));
|
|
165
|
+
|
|
166
|
+
const transitionList: TransitionStats[] = [];
|
|
167
|
+
for (const accumulator of transitions.values()) {
|
|
168
|
+
transitionList.push({
|
|
169
|
+
source: accumulator.source,
|
|
170
|
+
target: accumulator.target,
|
|
171
|
+
count: accumulator.count,
|
|
172
|
+
caseCount: accumulator.caseCount,
|
|
173
|
+
duration: accumulator.duration.stats(),
|
|
174
|
+
isSelfLoop: accumulator.source === accumulator.target,
|
|
175
|
+
isBackEdge: false,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
transitionList.sort(
|
|
179
|
+
(a, b) =>
|
|
180
|
+
b.count - a.count || compareStrings(a.source, b.source) || compareStrings(a.target, b.target),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
activities: activityList,
|
|
185
|
+
transitions: transitionList,
|
|
186
|
+
startActivities: toSortedRecord(startActivities),
|
|
187
|
+
endActivities: toSortedRecord(endActivities),
|
|
188
|
+
totals: { cases: normalized.cases.length, events, variants: variantKeys.size },
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Code-unit comparison — locale-independent, so the order is the same on every machine. */
|
|
193
|
+
function compareStrings(a: string, b: string): number {
|
|
194
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Key order is part of the output, so it is sorted rather than left to insertion order. */
|
|
198
|
+
function toSortedRecord(counts: Map<string, number>): Record<string, number> {
|
|
199
|
+
const out: Record<string, number> = {};
|
|
200
|
+
for (const key of [...counts.keys()].sort()) out[key] = counts.get(key) as number;
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
DURATION_SAMPLE_CAP,
|
|
5
|
+
DurationSampler,
|
|
6
|
+
durationStats,
|
|
7
|
+
emptyDurationStats,
|
|
8
|
+
} from "./duration-stats";
|
|
9
|
+
|
|
10
|
+
describe("durationStats", () => {
|
|
11
|
+
it("computes every member from a hand-checkable sample set", () => {
|
|
12
|
+
// 10 samples, 1000 apart: sum 55 000, mean 5500, median (5000 + 6000) / 2 = 5500,
|
|
13
|
+
// p90 at pos 0.9 * 9 = 8.1 → 9000 + 0.1 * 1000 = 9100, trim floor(10 * 0.1) = 1 each
|
|
14
|
+
// tail → mean of 2000…9000 = 5500.
|
|
15
|
+
const samples = [1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10_000];
|
|
16
|
+
expect(durationStats(samples)).toEqual({
|
|
17
|
+
min: 1000,
|
|
18
|
+
max: 10_000,
|
|
19
|
+
mean: 5500,
|
|
20
|
+
median: 5500,
|
|
21
|
+
p90: 9100,
|
|
22
|
+
sum: 55_000,
|
|
23
|
+
trimmedMean: 5500,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("lets the trimmed mean diverge from the mean when the tails are extreme", () => {
|
|
28
|
+
const samples = [0, 10, 10, 10, 10, 10, 10, 10, 10, 1000];
|
|
29
|
+
const stats = durationStats(samples);
|
|
30
|
+
expect(stats.mean).toBe(108);
|
|
31
|
+
// One sample trimmed from each tail removes the 0 and the 1000.
|
|
32
|
+
expect(stats.trimmedMean).toBe(10);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("degrades the trimmed mean to the plain mean when trimming would leave nothing", () => {
|
|
36
|
+
// n = 4 → floor(0.4) = 0 trimmed, so the two agree.
|
|
37
|
+
const stats = durationStats([1, 2, 3, 10]);
|
|
38
|
+
expect(stats.trimmedMean).toBe(stats.mean);
|
|
39
|
+
expect(stats.trimmedMean).toBe(4);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("drops non-finite samples instead of poisoning every statistic", () => {
|
|
43
|
+
expect(durationStats([Number.NaN, 10, Number.POSITIVE_INFINITY, 30])).toMatchObject({
|
|
44
|
+
min: 10,
|
|
45
|
+
max: 30,
|
|
46
|
+
mean: 20,
|
|
47
|
+
sum: 40,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("answers all zeros for an empty sample set", () => {
|
|
52
|
+
expect(durationStats([])).toEqual(emptyDurationStats());
|
|
53
|
+
expect(durationStats([Number.NaN])).toEqual(emptyDurationStats());
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("does not mutate the caller's array", () => {
|
|
57
|
+
const samples = [30, 10, 20];
|
|
58
|
+
durationStats(samples);
|
|
59
|
+
expect(samples).toEqual([30, 10, 20]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("DurationSampler", () => {
|
|
64
|
+
it("keeps every sample below the cap", () => {
|
|
65
|
+
const sampler = new DurationSampler(1, 8);
|
|
66
|
+
for (const value of [4, 8, 15, 16, 23, 42]) sampler.add(value);
|
|
67
|
+
expect(sampler.size).toBe(6);
|
|
68
|
+
expect(sampler.stats()).toEqual(durationStats([4, 8, 15, 16, 23, 42]));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("keeps sum, mean, min and max EXACT past the cap", () => {
|
|
72
|
+
const sampler = new DurationSampler(7, 16);
|
|
73
|
+
let expectedSum = 0;
|
|
74
|
+
for (let i = 1; i <= 5000; i += 1) {
|
|
75
|
+
sampler.add(i);
|
|
76
|
+
expectedSum += i;
|
|
77
|
+
}
|
|
78
|
+
const stats = sampler.stats();
|
|
79
|
+
expect(sampler.size).toBe(5000);
|
|
80
|
+
expect(stats.sum).toBe(expectedSum);
|
|
81
|
+
expect(stats.mean).toBe(expectedSum / 5000);
|
|
82
|
+
expect(stats.min).toBe(1);
|
|
83
|
+
expect(stats.max).toBe(5000);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("is deterministic — the same seed and inputs give the same order statistics", () => {
|
|
87
|
+
const build = (): ReturnType<DurationSampler["stats"]> => {
|
|
88
|
+
const sampler = new DurationSampler(0x1234, 32);
|
|
89
|
+
for (let i = 0; i < 2000; i += 1) sampler.add((i * 37) % 991);
|
|
90
|
+
return sampler.stats();
|
|
91
|
+
};
|
|
92
|
+
expect(build()).toEqual(build());
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("estimates the median from the reservoir within a few percent of the truth", () => {
|
|
96
|
+
const sampler = new DurationSampler(99, 512);
|
|
97
|
+
const all: number[] = [];
|
|
98
|
+
for (let i = 0; i < 20_000; i += 1) {
|
|
99
|
+
sampler.add(i);
|
|
100
|
+
all.push(i);
|
|
101
|
+
}
|
|
102
|
+
const truth = durationStats(all).median;
|
|
103
|
+
expect(Math.abs(sampler.stats().median - truth) / truth).toBeLessThan(0.05);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("ignores non-finite samples and reports zeros when nothing was collected", () => {
|
|
107
|
+
const sampler = new DurationSampler();
|
|
108
|
+
sampler.add(Number.NaN);
|
|
109
|
+
expect(sampler.size).toBe(0);
|
|
110
|
+
expect(sampler.stats()).toEqual(emptyDurationStats());
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("defaults to the documented cap", () => {
|
|
114
|
+
expect(DURATION_SAMPLE_CAP).toBe(4096);
|
|
115
|
+
});
|
|
116
|
+
});
|