@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,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { BPI_2012_ACTIVITIES, generateBpi2012Subset } from "./generate-bpi-2012-subset";
4
+
5
+ describe("generateBpi2012Subset", () => {
6
+ it("is deterministic — the same seed produces a byte-identical log", () => {
7
+ const a = generateBpi2012Subset({ cases: 50, seed: 42 });
8
+ const b = generateBpi2012Subset({ cases: 50, seed: 42 });
9
+ expect(JSON.stringify(a)).toBe(JSON.stringify(b));
10
+ });
11
+
12
+ it("a different seed produces a different log", () => {
13
+ const a = generateBpi2012Subset({ cases: 50, seed: 1 });
14
+ const b = generateBpi2012Subset({ cases: 50, seed: 2 });
15
+ expect(JSON.stringify(a)).not.toBe(JSON.stringify(b));
16
+ });
17
+
18
+ it("every case starts with A_SUBMITTED", () => {
19
+ const log = generateBpi2012Subset({ cases: 30, seed: 3 });
20
+ const firstByCase = new Map<string, string>();
21
+ for (const event of log.events) {
22
+ if (!firstByCase.has(event.caseId)) firstByCase.set(event.caseId, event.activity);
23
+ }
24
+ for (const activity of firstByCase.values()) expect(activity).toBe("A_SUBMITTED");
25
+ });
26
+
27
+ it("emits exactly the declared BPI-2012 vocabulary — set equality in both directions (#228)", () => {
28
+ // A large case count so every branch of buildTrace() — including the low-probability
29
+ // O_CANCELLED path — has run at least once. Checking BOTH directions (declared ⊆
30
+ // emitted AND emitted ⊆ declared) is the point: a subset-only check in either direction
31
+ // cannot catch the constant and the generator silently drifting apart.
32
+ const log = generateBpi2012Subset({ cases: 5_000, seed: 5 });
33
+ const declared = new Set<string>(BPI_2012_ACTIVITIES);
34
+ const emitted = new Set<string>(log.events.map((event) => event.activity));
35
+ for (const activity of emitted) expect(declared.has(activity)).toBe(true);
36
+ for (const activity of declared) expect(emitted.has(activity)).toBe(true);
37
+ });
38
+
39
+ it("cases below 1 yield an empty log", () => {
40
+ expect(generateBpi2012Subset({ cases: 0, seed: 1 })).toEqual({ events: [] });
41
+ });
42
+
43
+ it("produces a well-formed 13 000-case log within a reasonable time", () => {
44
+ const started = performance.now();
45
+ const log = generateBpi2012Subset({ cases: 13_000, seed: 1 });
46
+ const elapsed = performance.now() - started;
47
+ expect(log.events.length).toBeGreaterThan(100_000);
48
+ expect(elapsed).toBeLessThan(2000);
49
+ });
50
+ });
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Seeded BPI-2012-shaped event-log generator — RM-053.
3
+ *
4
+ * A SEPARATE generator from `synthetic-log.ts` (RM-049), not a second copy of it: RM-049's
5
+ * `generateSyntheticLog` produces an order-to-cash shaped log (Create Order, Check Credit,
6
+ * Ship Order, …); this one produces a log shaped like the public BPI Challenge 2012 event
7
+ * log (van Dongen, 2012) — a Dutch financial institution's loan-application process, whose
8
+ * activity vocabulary follows the dataset's own three-lifecycle-prefix convention
9
+ * (`A_` application state, `O_` offer state, `W_` work item). The BPI Challenge datasets are
10
+ * a public academic research corpus published for the annual Business Process Intelligence
11
+ * Challenge; naming one here names a published research artifact, not a commercial vendor.
12
+ *
13
+ * This is what analysis §4 R21 asks for: a ~13,000-case fixture large enough to exercise
14
+ * `discoverGraph`'s performance budget (already covered by RM-049's own
15
+ * `generateSyntheticLog({ cases: 13_000 })` in `discover-graph.test.ts`) and, from RM-051
16
+ * onward, a Storybook "LargeGraph" story that wants a visually denser, more tangled graph
17
+ * than the order-to-cash shape produces (more branching, more rework, a wider activity
18
+ * vocabulary).
19
+ *
20
+ * Determinism is the whole contract, exactly as in `synthetic-log.ts`:
21
+ * `generateBpi2012Subset({ cases: n, seed: s })` returns a byte-identical log on every run.
22
+ *
23
+ * ## This module is BROWSER-SAFE — no Node built-ins
24
+ *
25
+ * Deliberately: `discoverGraph`/`extractVariants` demos (the RM-053 placeholder Storybook
26
+ * story, and any future browser-rendered fixture preview) import this module directly, and
27
+ * Storybook's story tests run in a real browser (`@storybook/addon-vitest`'s Playwright
28
+ * provider) where `node:fs`/`node:path`/`node:url` are externalized and throw on access. So
29
+ * this file exports the PURE generator only — no filesystem write, no `import.meta.url`
30
+ * resolution, no `process.argv` read. The file that writes the on-disk fixture is the
31
+ * sibling `generate-bpi-2012-subset.write.ts` (see below), which is the only place those
32
+ * Node-only imports appear.
33
+ *
34
+ * ## Producing the on-disk fixture
35
+ *
36
+ * The 13k-case output is a ~250k-row JSON document — too large to commit as a binary (see
37
+ * the repo's "no committed large binary fixture" convention, already followed by
38
+ * `synthetic-log.ts`). It is a GENERATED artifact: run
39
+ *
40
+ * ```
41
+ * pnpm --filter @elabs-ai/components-process generate:fixtures
42
+ * ```
43
+ *
44
+ * which writes `bpi-2012-subset.json` next to this file (git-ignored — see the repo root
45
+ * `.gitignore`), via `generate-bpi-2012-subset.write.ts` (run through `vitest`, not `tsx` —
46
+ * see that file's docblock for why). A consumer of the generated JSON should treat it as a
47
+ * build artifact, not a checked-in fixture; import {@link generateBpi2012Subset} directly
48
+ * wherever possible instead of reading the file.
49
+ */
50
+ import type { EventLog, EventRow } from "../types";
51
+
52
+ /** Epoch ms the first case starts at: 2012-01-02T08:00:00.000Z, a Monday morning. */
53
+ export const BPI_2012_SUBSET_EPOCH = 1325487600000;
54
+
55
+ const MINUTE = 60_000;
56
+
57
+ /** Options for {@link generateBpi2012Subset}. */
58
+ export interface Bpi2012SubsetOptions {
59
+ /** How many cases to generate. Values below 1 yield an empty log. */
60
+ cases: number;
61
+ /** PRNG seed. The same seed always produces the same log. */
62
+ seed?: number;
63
+ /** Epoch ms the first case starts at. Defaults to {@link BPI_2012_SUBSET_EPOCH}. */
64
+ startTime?: number;
65
+ }
66
+
67
+ /**
68
+ * A deterministic 32-bit PRNG (mulberry32).
69
+ *
70
+ * Deliberately its OWN copy rather than one imported from `synthetic-log.ts`: that file's
71
+ * `mulberry32` is intentionally unexported (its own doc comment explains why — a fixture
72
+ * generator's output must not shift because an unrelated generator's internals changed), and
73
+ * this generator's output is equally a byte-identical fixture contract. Same reasoning,
74
+ * same repo convention, independently applied.
75
+ */
76
+ function mulberry32(seed: number): () => number {
77
+ let a = seed >>> 0;
78
+ return () => {
79
+ a = (a + 0x6d2b79f5) >>> 0;
80
+ let t = a;
81
+ t = Math.imul(t ^ (t >>> 15), t | 1);
82
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
83
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
84
+ };
85
+ }
86
+
87
+ const RESOURCES = [
88
+ "Loan Officer 1",
89
+ "Loan Officer 2",
90
+ "Loan Officer 3",
91
+ "Credit Assessor",
92
+ "Automated System",
93
+ ] as const;
94
+
95
+ /**
96
+ * Activity vocabulary, following the public BPI Challenge 2012 log's own naming convention:
97
+ * `A_` (application), `O_` (offer) and `W_` (work item) lifecycle prefixes.
98
+ */
99
+ export const BPI_2012_ACTIVITIES = [
100
+ "A_SUBMITTED",
101
+ "A_PARTLYSUBMITTED",
102
+ "A_PREACCEPTED",
103
+ "W_Completeren aanvraag",
104
+ "A_ACCEPTED",
105
+ "O_SELECTED",
106
+ "A_FINALIZED",
107
+ "O_CREATED",
108
+ "O_SENT",
109
+ "W_Nabellen offertes",
110
+ "O_SENT_BACK",
111
+ "A_REGISTERED",
112
+ "A_APPROVED",
113
+ "A_ACTIVATED",
114
+ "O_ACCEPTED",
115
+ "A_DECLINED",
116
+ "O_DECLINED",
117
+ "O_CANCELLED",
118
+ "A_CANCELLED",
119
+ ] as const;
120
+
121
+ /** One activity name from {@link BPI_2012_ACTIVITIES}. */
122
+ export type Bpi2012Activity = (typeof BPI_2012_ACTIVITIES)[number];
123
+
124
+ /** Build the activity sequence of a single case. Pure given `random`. */
125
+ function buildTrace(random: () => number): Bpi2012Activity[] {
126
+ const trace: Bpi2012Activity[] = ["A_SUBMITTED", "A_PARTLYSUBMITTED"];
127
+
128
+ if (random() < 0.06) {
129
+ trace.push("A_DECLINED");
130
+ return trace;
131
+ }
132
+
133
+ trace.push("A_PREACCEPTED", "W_Completeren aanvraag");
134
+
135
+ // Rework: an incomplete dossier sends the applicant back through completion, at most twice.
136
+ let reworks = 0;
137
+ while (reworks < 2 && random() < 0.18) {
138
+ trace.push("W_Completeren aanvraag");
139
+ reworks += 1;
140
+ }
141
+
142
+ if (random() < 0.08) {
143
+ trace.push("A_CANCELLED");
144
+ return trace;
145
+ }
146
+
147
+ trace.push(
148
+ "A_ACCEPTED",
149
+ "O_SELECTED",
150
+ "A_FINALIZED",
151
+ "O_CREATED",
152
+ "O_SENT",
153
+ "W_Nabellen offertes",
154
+ );
155
+
156
+ // About a quarter of offers are sent back at least once before acceptance.
157
+ if (random() < 0.25) trace.push("O_SENT_BACK", "O_SENT", "W_Nabellen offertes");
158
+
159
+ // About 1 in 10 offers ends without acceptance: most are declined outright, a smaller
160
+ // share are withdrawn (O_CANCELLED) while still outstanding — both close the case, one
161
+ // via A_CANCELLED and the other via either terminal state, mirroring the public BPI 2012
162
+ // log's own O_CANCELLED lifecycle event (#228 — this branch is what makes it reachable;
163
+ // see generate-bpi-2012-subset.test.ts's vocabulary set-equality test).
164
+ if (random() < 0.1) {
165
+ if (random() < 0.3) {
166
+ trace.push("O_CANCELLED", "A_CANCELLED");
167
+ } else {
168
+ trace.push("O_DECLINED", random() < 0.5 ? "A_CANCELLED" : "A_DECLINED");
169
+ }
170
+ return trace;
171
+ }
172
+
173
+ trace.push("O_ACCEPTED", "A_REGISTERED", "A_APPROVED", "A_ACTIVATED");
174
+ return trace;
175
+ }
176
+
177
+ /**
178
+ * Generate a synthetic BPI-2012-shaped loan-application log.
179
+ *
180
+ * Every event is an INTERVAL event (`startTimestamp` + `timestamp`), and cases are emitted
181
+ * as `caseId`s of the form `bpi-000001`, zero-padded so lexical and numeric order agree —
182
+ * the same shape `generateSyntheticLog` uses, so downstream consumers do not need a second
183
+ * case-id convention.
184
+ */
185
+ export function generateBpi2012Subset(options: Bpi2012SubsetOptions): EventLog {
186
+ const total = Math.max(0, Math.floor(options.cases));
187
+ const random = mulberry32(options.seed ?? 1);
188
+ const epoch = options.startTime ?? BPI_2012_SUBSET_EPOCH;
189
+
190
+ const events: EventRow[] = [];
191
+ const pad = String(total).length;
192
+
193
+ for (let index = 0; index < total; index += 1) {
194
+ const caseId = `bpi-${String(index + 1).padStart(Math.max(6, pad), "0")}`;
195
+ const trace = buildTrace(random);
196
+
197
+ let cursor = epoch + index * 47 * MINUTE + Math.floor(random() * 30) * MINUTE;
198
+
199
+ for (const activity of trace) {
200
+ const idle = Math.floor(random() * 240) * MINUTE;
201
+ const work = (1 + Math.floor(random() * 20)) * MINUTE;
202
+ const start = cursor + idle;
203
+ const end = start + work;
204
+ events.push({
205
+ caseId,
206
+ activity,
207
+ timestamp: end,
208
+ startTimestamp: start,
209
+ resource: RESOURCES[Math.floor(random() * RESOURCES.length)] as string,
210
+ });
211
+ cursor = end;
212
+ }
213
+ }
214
+
215
+ return { events };
216
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Fixture writer for {@link generateBpi2012Subset} — RM-053.
3
+ *
4
+ * Invoked via `pnpm --filter @elabs-ai/components-process generate:fixtures`, which runs
5
+ * `vitest run --config vitest.fixtures.config.ts` against exactly this file. Deliberately
6
+ * NOT `tsx` (tried, then reverted — a fixtures change should not carry a repo-wide
7
+ * `esbuild`/build-tool bump for a package's own dev script): `vitest` is already a
8
+ * devDependency of this package and executes TypeScript directly, so it is the toolchain
9
+ * already on hand for running one script.
10
+ *
11
+ * This file is a genuine vitest test (a `describe`/`it` pair, with a real assertion) so
12
+ * `vitest run` accepts it as a normal test file — the write to disk is the test's side
13
+ * effect. It is named `.write.ts`, not `.test.ts`/`.spec.ts`, so the package's real
14
+ * `vitest.config.ts` (whose default `include` only matches `*.test.*`/`*.spec.*`) never
15
+ * picks it up: `pnpm test` never runs this file, and `pnpm generate:fixtures` never runs
16
+ * the package's real test suite. `vitest.fixtures.config.ts`'s `test.include` names this
17
+ * file explicitly, which is what makes it reachable at all.
18
+ */
19
+ import { writeFileSync } from "node:fs";
20
+ import { dirname, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { describe, expect, it } from "vitest";
23
+ import { generateBpi2012Subset } from "./generate-bpi-2012-subset";
24
+
25
+ /** Resolved path of the generated (git-ignored) fixture file, next to this module. */
26
+ export const BPI_2012_SUBSET_OUTPUT_PATH = resolve(
27
+ dirname(fileURLToPath(import.meta.url)),
28
+ "bpi-2012-subset.json",
29
+ );
30
+
31
+ describe("generate:fixtures", () => {
32
+ it("writes the BPI-2012-shaped 13k-case fixture to disk", () => {
33
+ const log = generateBpi2012Subset({ cases: 13_000, seed: 1 });
34
+ writeFileSync(BPI_2012_SUBSET_OUTPUT_PATH, JSON.stringify(log));
35
+ console.log(
36
+ `Wrote ${BPI_2012_SUBSET_OUTPUT_PATH} (${log.events.length} events across 13000 cases).`,
37
+ );
38
+ expect(log.events.length).toBeGreaterThan(0);
39
+ });
40
+ });
@@ -0,0 +1,200 @@
1
+ {
2
+ "events": [
3
+ {
4
+ "caseId": "case-1",
5
+ "activity": "Create Order",
6
+ "timestamp": "2026-01-05T09:00:00.000Z",
7
+ "resource": "A. Novak"
8
+ },
9
+ {
10
+ "caseId": "case-1",
11
+ "activity": "Check Credit",
12
+ "timestamp": "2026-01-05T10:00:00.000Z",
13
+ "resource": "Credit Service"
14
+ },
15
+ {
16
+ "caseId": "case-1",
17
+ "activity": "Approve Order",
18
+ "timestamp": "2026-01-05T11:00:00.000Z",
19
+ "resource": "C. Okafor"
20
+ },
21
+ {
22
+ "caseId": "case-1",
23
+ "activity": "Ship Order",
24
+ "timestamp": "2026-01-05T12:00:00.000Z",
25
+ "resource": "Warehouse Robot"
26
+ },
27
+ {
28
+ "caseId": "case-1",
29
+ "activity": "Send Invoice",
30
+ "timestamp": "2026-01-05T13:00:00.000Z",
31
+ "resource": "D. Lindqvist"
32
+ },
33
+ {
34
+ "caseId": "case-1",
35
+ "activity": "Receive Payment",
36
+ "timestamp": "2026-01-05T14:00:00.000Z",
37
+ "resource": "E. Haddad"
38
+ },
39
+ {
40
+ "caseId": "case-2",
41
+ "activity": "Create Order",
42
+ "timestamp": "2026-01-06T09:00:00.000Z",
43
+ "resource": "A. Novak"
44
+ },
45
+ {
46
+ "caseId": "case-2",
47
+ "activity": "Check Credit",
48
+ "timestamp": "2026-01-06T10:00:00.000Z",
49
+ "resource": "Credit Service"
50
+ },
51
+ {
52
+ "caseId": "case-2",
53
+ "activity": "Approve Order",
54
+ "timestamp": "2026-01-06T11:00:00.000Z",
55
+ "resource": "C. Okafor"
56
+ },
57
+ {
58
+ "caseId": "case-2",
59
+ "activity": "Ship Order",
60
+ "timestamp": "2026-01-06T12:00:00.000Z",
61
+ "resource": "Warehouse Robot"
62
+ },
63
+ {
64
+ "caseId": "case-2",
65
+ "activity": "Send Invoice",
66
+ "timestamp": "2026-01-06T13:00:00.000Z",
67
+ "resource": "D. Lindqvist"
68
+ },
69
+ {
70
+ "caseId": "case-2",
71
+ "activity": "Receive Payment",
72
+ "timestamp": "2026-01-06T14:00:00.000Z",
73
+ "resource": "E. Haddad"
74
+ },
75
+ {
76
+ "caseId": "case-3",
77
+ "activity": "Create Order",
78
+ "timestamp": "2026-01-07T09:00:00.000Z",
79
+ "resource": "A. Novak"
80
+ },
81
+ {
82
+ "caseId": "case-3",
83
+ "activity": "Check Credit",
84
+ "timestamp": "2026-01-07T10:00:00.000Z",
85
+ "resource": "Credit Service"
86
+ },
87
+ {
88
+ "caseId": "case-3",
89
+ "activity": "Reject Order",
90
+ "timestamp": "2026-01-07T11:00:00.000Z",
91
+ "resource": "C. Okafor"
92
+ },
93
+ {
94
+ "caseId": "case-4",
95
+ "activity": "Create Order",
96
+ "timestamp": "2026-01-08T09:00:00.000Z",
97
+ "resource": "A. Novak"
98
+ },
99
+ {
100
+ "caseId": "case-4",
101
+ "activity": "Check Credit",
102
+ "timestamp": "2026-01-08T10:00:00.000Z",
103
+ "resource": "Credit Service"
104
+ },
105
+ {
106
+ "caseId": "case-4",
107
+ "activity": "Amend Order",
108
+ "timestamp": "2026-01-08T11:00:00.000Z",
109
+ "resource": "B. Ferreira"
110
+ },
111
+ {
112
+ "caseId": "case-4",
113
+ "activity": "Check Credit",
114
+ "timestamp": "2026-01-08T12:00:00.000Z",
115
+ "resource": "Credit Service"
116
+ },
117
+ {
118
+ "caseId": "case-4",
119
+ "activity": "Approve Order",
120
+ "timestamp": "2026-01-08T13:00:00.000Z",
121
+ "resource": "C. Okafor"
122
+ },
123
+ {
124
+ "caseId": "case-4",
125
+ "activity": "Ship Order",
126
+ "timestamp": "2026-01-08T14:00:00.000Z",
127
+ "resource": "Warehouse Robot"
128
+ },
129
+ {
130
+ "caseId": "case-4",
131
+ "activity": "Send Invoice",
132
+ "timestamp": "2026-01-08T15:00:00.000Z",
133
+ "resource": "D. Lindqvist"
134
+ },
135
+ {
136
+ "caseId": "case-4",
137
+ "activity": "Receive Payment",
138
+ "timestamp": "2026-01-08T16:00:00.000Z",
139
+ "resource": "E. Haddad"
140
+ },
141
+ {
142
+ "caseId": "case-5",
143
+ "activity": "Create Order",
144
+ "timestamp": "2026-01-09T09:00:00.000Z",
145
+ "resource": "A. Novak"
146
+ },
147
+ {
148
+ "caseId": "case-5",
149
+ "activity": "Check Credit",
150
+ "timestamp": "2026-01-09T11:00:00.000Z",
151
+ "resource": "Credit Service"
152
+ },
153
+ {
154
+ "caseId": "case-5",
155
+ "activity": "Approve Order",
156
+ "timestamp": "2026-01-09T13:00:00.000Z",
157
+ "resource": "C. Okafor"
158
+ },
159
+ {
160
+ "caseId": "case-5",
161
+ "activity": "Send Invoice",
162
+ "timestamp": "2026-01-09T15:00:00.000Z",
163
+ "resource": "D. Lindqvist"
164
+ },
165
+ {
166
+ "caseId": "case-5",
167
+ "activity": "Ship Order",
168
+ "timestamp": "2026-01-09T17:00:00.000Z",
169
+ "resource": "Warehouse Robot"
170
+ },
171
+ {
172
+ "caseId": "case-5",
173
+ "activity": "Receive Payment",
174
+ "timestamp": "2026-01-09T19:00:00.000Z",
175
+ "resource": "E. Haddad"
176
+ }
177
+ ],
178
+ "caseAttributes": {
179
+ "case-1": {
180
+ "region": "North",
181
+ "segment": "Enterprise"
182
+ },
183
+ "case-2": {
184
+ "region": "South",
185
+ "segment": "SMB"
186
+ },
187
+ "case-3": {
188
+ "region": "East",
189
+ "segment": "SMB"
190
+ },
191
+ "case-4": {
192
+ "region": "West",
193
+ "segment": "Mid-market"
194
+ },
195
+ "case-5": {
196
+ "region": "North",
197
+ "segment": "Enterprise"
198
+ }
199
+ }
200
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { discoverGraph } from "../discover-graph";
4
+ import { normalizeLog } from "../event-log";
5
+ import { extractVariants } from "../extract-variants";
6
+ import { generateSyntheticLog, SYNTHETIC_ACTIVITIES, SYNTHETIC_LOG_EPOCH } from "./synthetic-log";
7
+
8
+ describe("generateSyntheticLog", () => {
9
+ it("is deterministic — the same seed produces a byte-identical log", () => {
10
+ expect(generateSyntheticLog({ cases: 50, seed: 4 })).toEqual(
11
+ generateSyntheticLog({ cases: 50, seed: 4 }),
12
+ );
13
+ });
14
+
15
+ it("produces a different log for a different seed", () => {
16
+ expect(generateSyntheticLog({ cases: 50, seed: 4 })).not.toEqual(
17
+ generateSyntheticLog({ cases: 50, seed: 5 }),
18
+ );
19
+ });
20
+
21
+ it("emits exactly the requested number of cases, in order", () => {
22
+ const log = generateSyntheticLog({ cases: 25, seed: 1 });
23
+ const normalized = normalizeLog(log);
24
+ expect(normalized.totals.cases).toBe(25);
25
+ expect(normalized.cases[0]?.caseId).toBe("case-00001");
26
+ expect(normalized.cases[24]?.caseId).toBe("case-00025");
27
+ // Zero-padded so lexical and numeric order agree.
28
+ expect([...normalized.cases.map((c) => c.caseId)].sort()).toEqual(
29
+ normalized.cases.map((c) => c.caseId),
30
+ );
31
+ });
32
+
33
+ it("uses only the declared activity vocabulary", () => {
34
+ const vocabulary = new Set<string>(SYNTHETIC_ACTIVITIES);
35
+ for (const event of generateSyntheticLog({ cases: 300, seed: 2 }).events) {
36
+ expect(vocabulary.has(event.activity)).toBe(true);
37
+ }
38
+ });
39
+
40
+ it("starts at the fixed epoch and never reads a wall clock", () => {
41
+ const log = generateSyntheticLog({ cases: 3, seed: 1 });
42
+ const first = log.events[0];
43
+ expect(typeof first?.timestamp).toBe("number");
44
+ expect(first?.timestamp as number).toBeGreaterThanOrEqual(SYNTHETIC_LOG_EPOCH);
45
+ // Ten years of headroom: a wall-clock leak would land far outside this.
46
+ expect(first?.timestamp as number).toBeLessThan(SYNTHETIC_LOG_EPOCH + 315_360_000_000);
47
+ });
48
+
49
+ it("honours an explicit start time", () => {
50
+ const log = generateSyntheticLog({ cases: 1, seed: 1, startTime: 0 });
51
+ expect(log.events[0]?.startTimestamp as number).toBeLessThan(315_360_000_000);
52
+ });
53
+
54
+ it("emits interval events, so activity durations are real", () => {
55
+ const graph = discoverGraph(generateSyntheticLog({ cases: 300, seed: 6 }));
56
+ for (const activity of graph.activities) {
57
+ expect(activity.duration.min).toBeGreaterThan(0);
58
+ }
59
+ expect(graph.transitions.some((edge) => edge.duration.max > 0)).toBe(true);
60
+ });
61
+
62
+ it("always starts a case with Create Order and orders events in time", () => {
63
+ for (const kase of normalizeLog(generateSyntheticLog({ cases: 200, seed: 8 })).cases) {
64
+ expect(kase.events[0]?.activity).toBe("Create Order");
65
+ for (let i = 1; i < kase.events.length; i += 1) {
66
+ expect(kase.events[i]?.start).toBeGreaterThanOrEqual(kase.events[i - 1]?.start ?? 0);
67
+ }
68
+ }
69
+ });
70
+
71
+ it("contains the shapes a process view has to handle: branching, rework and a self-loop", () => {
72
+ const graph = discoverGraph(generateSyntheticLog({ cases: 1000, seed: 1 }));
73
+ const ids = new Set(graph.activities.map((a) => a.id));
74
+ // Two exception branches, both reachable.
75
+ expect(ids.has("Reject Order")).toBe(true);
76
+ expect(ids.has("Cancel Order")).toBe(true);
77
+ // A rework loop back to an earlier activity.
78
+ expect(
79
+ graph.transitions.some((e) => e.source === "Amend Order" && e.target === "Check Credit"),
80
+ ).toBe(true);
81
+ // And a genuine self-loop.
82
+ expect(graph.transitions.some((e) => e.isSelfLoop)).toBe(true);
83
+ // More than one ending, because not every order completes.
84
+ expect(Object.keys(graph.endActivities).length).toBeGreaterThan(1);
85
+ });
86
+
87
+ it("produces a long-tailed variant distribution, not one path repeated", () => {
88
+ const variants = extractVariants(generateSyntheticLog({ cases: 1000, seed: 1 }));
89
+ expect(variants.length).toBeGreaterThan(5);
90
+ // The most common path is dominant but far from the whole log.
91
+ expect(variants[0]?.share).toBeGreaterThan(0.2);
92
+ expect(variants[0]?.share).toBeLessThan(0.8);
93
+ });
94
+
95
+ it("attaches case attributes to every case", () => {
96
+ const log = generateSyntheticLog({ cases: 10, seed: 1 });
97
+ expect(Object.keys(log.caseAttributes ?? {})).toHaveLength(10);
98
+ expect(log.caseAttributes?.["case-00001"]).toMatchObject({
99
+ region: expect.any(String),
100
+ segment: expect.any(String),
101
+ orderValue: expect.any(Number),
102
+ });
103
+ });
104
+
105
+ it("answers a non-positive case count with an empty log", () => {
106
+ expect(generateSyntheticLog({ cases: 0 }).events).toEqual([]);
107
+ expect(generateSyntheticLog({ cases: -5 }).events).toEqual([]);
108
+ });
109
+ });