@elabs-ai/components-process 4.2.0 → 5.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 (144) hide show
  1. package/README.md +8 -1
  2. package/dist/core/index.d.ts +801 -3
  3. package/dist/core/index.js +1334 -0
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/index.d.ts +1889 -34
  6. package/dist/index.js +5512 -196
  7. package/dist/index.js.map +1 -1
  8. package/dist/test/index.d.ts +223 -5
  9. package/dist/test/index.js +346 -191
  10. package/dist/test/index.js.map +1 -1
  11. package/package.json +14 -13
  12. package/src/__contract__/case-table.contract.test.tsx +49 -0
  13. package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
  14. package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
  15. package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
  16. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  17. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  18. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  19. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  20. package/src/case-table/case-table.stories.tsx +89 -0
  21. package/src/case-table/case-table.test.tsx +148 -0
  22. package/src/case-table/case-table.tsx +144 -0
  23. package/src/case-table/columns.ts +116 -0
  24. package/src/case-table/index.ts +11 -0
  25. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  26. package/src/case-timeline/case-timeline-model.ts +112 -0
  27. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  28. package/src/case-timeline/case-timeline.test.tsx +51 -0
  29. package/src/case-timeline/case-timeline.tsx +109 -0
  30. package/src/case-timeline/index.ts +9 -0
  31. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  32. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  33. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  34. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  35. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  36. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  37. package/src/conformance-overlay/conformance-state.ts +220 -0
  38. package/src/conformance-overlay/index.ts +4 -0
  39. package/src/core/activity-color-scale.test.ts +107 -0
  40. package/src/core/activity-color-scale.ts +133 -0
  41. package/src/core/adapters/ocel.test.ts +112 -0
  42. package/src/core/adapters/ocel.ts +359 -0
  43. package/src/core/adapters/xes.test.ts +293 -0
  44. package/src/core/adapters/xes.ts +384 -0
  45. package/src/core/cases-from-log.test.ts +72 -0
  46. package/src/core/cases-from-log.ts +85 -0
  47. package/src/core/conformance.test.ts +80 -0
  48. package/src/core/conformance.ts +91 -0
  49. package/src/core/diff-graphs.test.ts +151 -0
  50. package/src/core/diff-graphs.ts +118 -0
  51. package/src/core/discover-object-centric-graph.test.ts +94 -0
  52. package/src/core/discover-object-centric-graph.ts +296 -0
  53. package/src/core/fixtures/ocel-sample.ts +82 -0
  54. package/src/core/fixtures/sample.xes +68 -0
  55. package/src/core/index.ts +113 -0
  56. package/src/core/reference-model.test.ts +43 -0
  57. package/src/core/reference-model.ts +116 -0
  58. package/src/core/replay-timeline.test.ts +161 -0
  59. package/src/core/replay-timeline.ts +260 -0
  60. package/src/core/segments.test.ts +185 -0
  61. package/src/core/segments.ts +153 -0
  62. package/src/core/token-replay.test.ts +218 -0
  63. package/src/core/token-replay.ts +456 -0
  64. package/src/core/types.ts +2 -2
  65. package/src/dotted-chart/compute-dots.test.ts +176 -0
  66. package/src/dotted-chart/compute-dots.ts +241 -0
  67. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  68. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  69. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  70. package/src/dotted-chart/dotted-chart.tsx +841 -0
  71. package/src/dotted-chart/index.ts +23 -0
  72. package/src/dotted-chart/use-element-size.ts +33 -0
  73. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  74. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  75. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  76. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  77. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  78. package/src/happy-path-editor/index.ts +4 -0
  79. package/src/index.ts +51 -1
  80. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  81. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  82. package/src/performance-spectrum/index.ts +25 -0
  83. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  84. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  85. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  86. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  87. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  88. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  89. package/src/process-compare/compare-model.ts +83 -0
  90. package/src/process-compare/compare-side.tsx +42 -0
  91. package/src/process-compare/diff-to-graph.ts +104 -0
  92. package/src/process-compare/index.ts +23 -0
  93. package/src/process-compare/process-compare.stories.tsx +184 -0
  94. package/src/process-compare/process-compare.test.tsx +224 -0
  95. package/src/process-compare/process-compare.tsx +251 -0
  96. package/src/process-explorer.stories.tsx +1 -1
  97. package/src/process-filter-bar/index.ts +2 -0
  98. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  99. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  100. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  101. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  102. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  103. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  104. package/src/process-map/activity-accent.ts +25 -0
  105. package/src/process-map/index.ts +1 -0
  106. package/src/process-map/map-model.test.ts +16 -0
  107. package/src/process-map/map-model.ts +323 -1
  108. package/src/process-map/object-centric-map.test.tsx +132 -0
  109. package/src/process-map/process-activity-node.tsx +152 -14
  110. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  111. package/src/process-map/process-map.stories.tsx +64 -0
  112. package/src/process-map/process-map.tsx +240 -16
  113. package/src/process-map/process-transition-edge.test.tsx +47 -0
  114. package/src/process-map/process-transition-edge.tsx +134 -7
  115. package/src/process-map/use-process-layout.ts +30 -9
  116. package/src/process-replay/congestion-heat.tsx +107 -0
  117. package/src/process-replay/index.ts +14 -0
  118. package/src/process-replay/process-replay.stories.tsx +168 -0
  119. package/src/process-replay/process-replay.test.tsx +170 -0
  120. package/src/process-replay/process-replay.tsx +285 -0
  121. package/src/process-replay/replay-controls.tsx +147 -0
  122. package/src/process-replay/replay-format.ts +83 -0
  123. package/src/process-replay/replay-tokens-context.ts +30 -0
  124. package/src/process-replay/use-controllable-value.ts +30 -0
  125. package/src/templates-process-explorer.stories.tsx +1304 -0
  126. package/src/test/contract.test.ts +66 -0
  127. package/src/test/contract.ts +107 -6
  128. package/src/test/doubles.test.tsx +87 -1
  129. package/src/test/doubles.tsx +174 -3
  130. package/src/test/index.ts +25 -1
  131. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  132. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  133. package/src/variant-explorer/coverage-bar.tsx +36 -0
  134. package/src/variant-explorer/index.ts +16 -0
  135. package/src/variant-explorer/sequence-chips.tsx +103 -0
  136. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  137. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  138. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  139. package/src/variant-explorer/variant-explorer.tsx +567 -0
  140. package/src/variant-explorer/variant-row.tsx +137 -0
  141. package/src/violation-list/index.ts +2 -0
  142. package/src/violation-list/violation-list.stories.tsx +73 -0
  143. package/src/violation-list/violation-list.test.tsx +84 -0
  144. package/src/violation-list/violation-list.tsx +259 -0
@@ -0,0 +1,185 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { discoverGraph } from "./discover-graph";
4
+ import { extractVariants } from "./extract-variants";
5
+ import { generateSyntheticLog } from "./fixtures/synthetic-log";
6
+ import {
7
+ durationQuartile,
8
+ durationQuartileThresholds,
9
+ quartileOf,
10
+ segmentKey,
11
+ segmentOrderByFrequency,
12
+ segmentOrderForVariant,
13
+ segmentsFor,
14
+ type SegmentDefinition,
15
+ } from "./segments";
16
+ import type { EventLog, EventRow } from "./types";
17
+
18
+ const H = 3_600_000;
19
+
20
+ /**
21
+ * Four cases through A → B → C → D. Waits (in hours) per segment:
22
+ *
23
+ * | case | A→B | B→C | C→D |
24
+ * | ---- | --- | --- | --- |
25
+ * | c1 | 1 | 8 | 2 |
26
+ * | c2 | 2 | 6 | 2 |
27
+ * | c3 | 3 | 4 | 2 |
28
+ * | c4 | 4 | 2 | 6 |
29
+ */
30
+ function hourlyCase(caseId: string, offset: number, waits: [number, number, number]): EventRow[] {
31
+ const [ab, bc, cd] = waits;
32
+ const t0 = offset * H;
33
+ return [
34
+ { caseId, activity: "A", timestamp: t0 },
35
+ { caseId, activity: "B", timestamp: t0 + ab * H },
36
+ { caseId, activity: "C", timestamp: t0 + (ab + bc) * H },
37
+ { caseId, activity: "D", timestamp: t0 + (ab + bc + cd) * H },
38
+ ];
39
+ }
40
+
41
+ const LOG: EventLog = {
42
+ events: [
43
+ ...hourlyCase("c1", 0, [1, 8, 2]),
44
+ ...hourlyCase("c2", 1, [2, 6, 2]),
45
+ ...hourlyCase("c3", 2, [3, 4, 2]),
46
+ ...hourlyCase("c4", 3, [4, 2, 6]),
47
+ ],
48
+ };
49
+
50
+ const ORDER: SegmentDefinition[] = [
51
+ { from: "A", to: "B" },
52
+ { from: "B", to: "C" },
53
+ { from: "C", to: "D" },
54
+ ];
55
+
56
+ describe("segmentsFor", () => {
57
+ const occurrences = segmentsFor(LOG, ORDER);
58
+
59
+ it("emits one occurrence per case per segment, in log then trace order", () => {
60
+ expect(occurrences).toHaveLength(12);
61
+ expect(occurrences.slice(0, 3).map((o) => [o.caseId, o.segment])).toEqual([
62
+ ["c1", segmentKey("A", "B")],
63
+ ["c1", segmentKey("B", "C")],
64
+ ["c1", segmentKey("C", "D")],
65
+ ]);
66
+ expect(occurrences[4]).toEqual({
67
+ segment: segmentKey("B", "C"),
68
+ caseId: "c2",
69
+ start: 3 * H,
70
+ end: 9 * H,
71
+ duration: 6 * H,
72
+ });
73
+ });
74
+
75
+ it("reproduces the hand-computed duration and quartile table", () => {
76
+ const table = ORDER.map((def) => {
77
+ const key = segmentKey(def.from, def.to);
78
+ const own = occurrences.filter((o) => o.segment === key);
79
+ const durations = own.map((o) => o.duration);
80
+ return own.map((o) => [o.caseId, o.duration / H, durationQuartile(o, durations)]);
81
+ });
82
+ expect(table).toEqual([
83
+ // A→B: 1,2,3,4 h → cuts 1.75 / 2.5 / 3.25
84
+ [
85
+ ["c1", 1, 1],
86
+ ["c2", 2, 2],
87
+ ["c3", 3, 3],
88
+ ["c4", 4, 4],
89
+ ],
90
+ // B→C: 8,6,4,2 h → cuts 3.5 / 5 / 6.5
91
+ [
92
+ ["c1", 8, 4],
93
+ ["c2", 6, 3],
94
+ ["c3", 4, 2],
95
+ ["c4", 2, 1],
96
+ ],
97
+ // C→D: 2,2,2,6 h → cuts 2 / 2 / 3 — ties land in the lowest quartile they reach
98
+ [
99
+ ["c1", 2, 1],
100
+ ["c2", 2, 1],
101
+ ["c3", 2, 1],
102
+ ["c4", 6, 4],
103
+ ],
104
+ ]);
105
+ });
106
+
107
+ it("skips pairs that are not in the order", () => {
108
+ const only = segmentsFor(LOG, [{ from: "B", to: "C" }]);
109
+ expect(only).toHaveLength(4);
110
+ expect(new Set(only.map((o) => o.segment))).toEqual(new Set([segmentKey("B", "C")]));
111
+ expect(segmentsFor(LOG, [{ from: "A", to: "C" }])).toEqual([]);
112
+ expect(segmentsFor(LOG, [])).toEqual([]);
113
+ });
114
+
115
+ it("ignores duplicate definitions and clamps an overlapping pair to zero duration", () => {
116
+ const overlapping: EventLog = {
117
+ events: [
118
+ { caseId: "x", activity: "A", startTimestamp: 0, timestamp: 10 },
119
+ { caseId: "x", activity: "B", startTimestamp: 5, timestamp: 20 },
120
+ ],
121
+ };
122
+ const out = segmentsFor(overlapping, [
123
+ { from: "A", to: "B" },
124
+ { from: "A", to: "B" },
125
+ ]);
126
+ expect(out).toEqual([
127
+ { segment: segmentKey("A", "B"), caseId: "x", start: 10, end: 10, duration: 0 },
128
+ ]);
129
+ });
130
+
131
+ it("is deterministic", () => {
132
+ const log = generateSyntheticLog({ cases: 50, seed: 3 });
133
+ const order = segmentOrderByFrequency(discoverGraph(log), 5);
134
+ expect(segmentsFor(log, order)).toEqual(segmentsFor(log, order));
135
+ });
136
+ });
137
+
138
+ describe("segmentOrderByFrequency", () => {
139
+ it("matches discoverGraph's own top-N transition ranking, order-stable", () => {
140
+ const log = generateSyntheticLog({ cases: 300, seed: 11 });
141
+ const graph = discoverGraph(log);
142
+ const top = segmentOrderByFrequency(graph, 6);
143
+ expect(top).toEqual(
144
+ graph.transitions.slice(0, 6).map((t) => ({ from: t.source, to: t.target })),
145
+ );
146
+ expect(segmentOrderByFrequency(discoverGraph(log), 6)).toEqual(top);
147
+ });
148
+
149
+ it("returns every transition without a limit, and none for a non-positive one", () => {
150
+ const graph = discoverGraph(LOG);
151
+ expect(segmentOrderByFrequency(graph)).toHaveLength(graph.transitions.length);
152
+ expect(segmentOrderByFrequency(graph, 0)).toEqual([]);
153
+ });
154
+ });
155
+
156
+ describe("segmentOrderForVariant", () => {
157
+ it("derives consecutive pairs from the variant sequence", () => {
158
+ const [variant] = extractVariants(LOG);
159
+ expect(segmentOrderForVariant(variant!)).toEqual(ORDER);
160
+ });
161
+
162
+ it("keeps a looped pair once, at its first position", () => {
163
+ const variant = { ...extractVariants(LOG)[0]!, sequence: ["A", "B", "A", "B", "C"] };
164
+ expect(segmentOrderForVariant(variant)).toEqual([
165
+ { from: "A", to: "B" },
166
+ { from: "B", to: "A" },
167
+ { from: "B", to: "C" },
168
+ ]);
169
+ });
170
+ });
171
+
172
+ describe("durationQuartileThresholds / quartileOf", () => {
173
+ it("answers zeros for an empty or all-non-finite sample", () => {
174
+ expect(durationQuartileThresholds([])).toEqual([0, 0, 0]);
175
+ expect(durationQuartileThresholds([Number.NaN])).toEqual([0, 0, 0]);
176
+ });
177
+
178
+ it("buckets with inclusive upper bounds", () => {
179
+ const cuts = durationQuartileThresholds([10, 20, 30, 40]);
180
+ expect(cuts).toEqual([17.5, 25, 32.5]);
181
+ expect([10, 17.5, 20, 25, 30, 32.5, 40].map((d) => quartileOf(d, cuts))).toEqual([
182
+ 1, 1, 2, 2, 3, 3, 4,
183
+ ]);
184
+ });
185
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Segment occurrences — RM-060.
3
+ *
4
+ * A performance spectrum (ProM's PSM) draws one line per case through a FIXED, chosen
5
+ * sequence of segments, where a segment is one directly-follows pair `from → to`. That
6
+ * needs something `TransitionStats` does not carry: the individual, time-ordered
7
+ * OCCURRENCES of a pair (which case, when it entered, when it left), not one aggregate
8
+ * across the whole log. Batching, FIFO violations and queue build-up are visible only
9
+ * in the occurrences.
10
+ *
11
+ * ## What an occurrence measures
12
+ *
13
+ * `start` is the moment the `from` event COMPLETES and `end` the moment the `to` event
14
+ * STARTS — the same idle-time reading `discoverGraph` defaults to, so a spectrum row and
15
+ * the map's edge median agree about one pair. For atomic events (the common case) start
16
+ * and completion coincide, so this is simply the two event timestamps. Overlapping
17
+ * (parallel) events would give `end < start`; `end` is clamped to `start`, so `duration`
18
+ * is never negative and a line never runs backwards.
19
+ *
20
+ * Deterministic and framework-free: no React, no `@elabs-ai/components-*`.
21
+ */
22
+
23
+ import { EDGE_KEY_SEPARATOR } from "./discover-graph";
24
+ import { asNormalizedLog, type AnyLog, type NormalizedEvent } from "./event-log";
25
+ import { ascending, quantileSorted } from "./scale";
26
+ import type { ProcessGraph, Variant } from "./types";
27
+
28
+ /** One row of a spectrum: the directly-follows pair `from → to`. */
29
+ export interface SegmentDefinition {
30
+ from: string;
31
+ to: string;
32
+ /** Display label. Defaults to `"from → to"` in a view. */
33
+ label?: string;
34
+ }
35
+
36
+ /** One case passing through one segment. */
37
+ export interface SegmentOccurrence {
38
+ /** The segment's key — {@link segmentKey}`(from, to)`. */
39
+ segment: string;
40
+ caseId: string;
41
+ /** When the `from` event completed, epoch ms. */
42
+ start: number;
43
+ /** When the `to` event started, epoch ms. Never before `start`. */
44
+ end: number;
45
+ /** `end - start`, in ms. */
46
+ duration: number;
47
+ }
48
+
49
+ /** A duration quartile, `1` = fastest quarter, `4` = slowest. */
50
+ export type DurationQuartile = 1 | 2 | 3 | 4;
51
+
52
+ /** The three cut points (25th, 50th, 75th percentile) a quartile is read against. */
53
+ export type QuartileThresholds = readonly [number, number, number];
54
+
55
+ /**
56
+ * The key of a segment — the same `source + separator + target` edge key `discoverGraph`
57
+ * and `ProcessMap` use, so a segment round-trips to a transition selection unchanged.
58
+ */
59
+ export function segmentKey(from: string, to: string): string {
60
+ return `${from}${EDGE_KEY_SEPARATOR}${to}`;
61
+ }
62
+
63
+ /**
64
+ * Every occurrence of the segments in `order`, walking each case's normalised sequence
65
+ * once. Pairs not in `order` are skipped — a spectrum shows a chosen sequence, never the
66
+ * whole graph. Output is in log order: case by case, and within a case in trace order.
67
+ * Duplicate definitions in `order` are ignored.
68
+ */
69
+ export function segmentsFor(log: AnyLog, order: readonly SegmentDefinition[]): SegmentOccurrence[] {
70
+ const wanted = new Set<string>();
71
+ for (const def of order) wanted.add(segmentKey(def.from, def.to));
72
+ const out: SegmentOccurrence[] = [];
73
+ if (wanted.size === 0) return out;
74
+
75
+ for (const kase of asNormalizedLog(log).cases) {
76
+ const trace = kase.events;
77
+ for (let i = 1; i < trace.length; i += 1) {
78
+ const previous = trace[i - 1] as NormalizedEvent;
79
+ const event = trace[i] as NormalizedEvent;
80
+ const key = segmentKey(previous.activity, event.activity);
81
+ if (!wanted.has(key)) continue;
82
+ const start = previous.end;
83
+ if (!Number.isFinite(start) || !Number.isFinite(event.start)) continue;
84
+ const end = Math.max(start, event.start);
85
+ out.push({ segment: key, caseId: kase.caseId, start, end, duration: end - start });
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * The `limit` most frequent directly-follows pairs of `graph`, busiest first. Reads
93
+ * `graph.transitions` in the order `discoverGraph` already ranks them (count descending,
94
+ * ties by source then target), so the two can never disagree about "the top N".
95
+ */
96
+ export function segmentOrderByFrequency(
97
+ graph: ProcessGraph,
98
+ limit: number = Number.POSITIVE_INFINITY,
99
+ ): SegmentDefinition[] {
100
+ const n = Math.max(0, Math.floor(limit));
101
+ return graph.transitions.slice(0, n).map((t) => ({ from: t.source, to: t.target }));
102
+ }
103
+
104
+ /**
105
+ * The consecutive pairs of `variant.sequence`, in path order. A pair repeated by a loop
106
+ * (`A, B, A, B`) appears once, at its first position — a spectrum has one row per segment.
107
+ */
108
+ export function segmentOrderForVariant(variant: Variant): SegmentDefinition[] {
109
+ const seen = new Set<string>();
110
+ const out: SegmentDefinition[] = [];
111
+ for (let i = 1; i < variant.sequence.length; i += 1) {
112
+ const from = variant.sequence[i - 1] as string;
113
+ const to = variant.sequence[i] as string;
114
+ const key = segmentKey(from, to);
115
+ if (seen.has(key)) continue;
116
+ seen.add(key);
117
+ out.push({ from, to });
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /**
123
+ * The 25th/50th/75th percentile of `durations` (R-7 interpolation, the same `quantile`
124
+ * every process view uses). Non-finite samples are dropped; an empty input gives zeros.
125
+ * Compute this ONCE per segment and read many occurrences against it with
126
+ * {@link quartileOf} — {@link durationQuartile} sorts on every call.
127
+ */
128
+ export function durationQuartileThresholds(durations: readonly number[]): QuartileThresholds {
129
+ const sorted: number[] = [];
130
+ for (const d of durations) if (Number.isFinite(d)) sorted.push(d);
131
+ sorted.sort(ascending);
132
+ if (sorted.length === 0) return [0, 0, 0];
133
+ return [quantileSorted(sorted, 0.25), quantileSorted(sorted, 0.5), quantileSorted(sorted, 0.75)];
134
+ }
135
+
136
+ /** Which quartile `duration` falls in, against precomputed thresholds (upper bounds inclusive). */
137
+ export function quartileOf(duration: number, thresholds: QuartileThresholds): DurationQuartile {
138
+ if (duration <= thresholds[0]) return 1;
139
+ if (duration <= thresholds[1]) return 2;
140
+ if (duration <= thresholds[2]) return 3;
141
+ return 4;
142
+ }
143
+
144
+ /**
145
+ * Buckets `occurrence` against ITS OWN segment's duration distribution — not the whole
146
+ * log's — which is PSM's colour convention: a slow line is slow for that segment.
147
+ */
148
+ export function durationQuartile(
149
+ occurrence: SegmentOccurrence,
150
+ allDurationsForSegment: readonly number[],
151
+ ): DurationQuartile {
152
+ return quartileOf(occurrence.duration, durationQuartileThresholds(allDurationsForSegment));
153
+ }
@@ -0,0 +1,218 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { EDGE_KEY_SEPARATOR } from "./discover-graph";
4
+ import { liftHappyPath, type HappyPath } from "./reference-model";
5
+ import {
6
+ DEVIATION_TYPES,
7
+ replayActivities,
8
+ replayTrace,
9
+ tokenReplay,
10
+ type Deviation,
11
+ } from "./token-replay";
12
+ import type { EventLog, EventRow } from "./types";
13
+
14
+ /** A → B (optional) → C (repeatable) → D → E. */
15
+ const HAPPY: HappyPath = {
16
+ id: "o2c",
17
+ label: "Order to cash",
18
+ steps: [
19
+ { activity: "A" },
20
+ { activity: "B", optional: true },
21
+ { activity: "C", repeatable: true },
22
+ { activity: "D" },
23
+ { activity: "E" },
24
+ ],
25
+ };
26
+ const model = liftHappyPath(HAPPY);
27
+
28
+ const T0 = Date.UTC(2026, 0, 5);
29
+
30
+ function rows(caseId: string, trace: string[], start = T0): EventRow[] {
31
+ return trace.map((activity, i) => ({ caseId, activity, timestamp: start + i * 60_000 }));
32
+ }
33
+
34
+ /**
35
+ * Hand-computed. `produced` counts the initial token plus one per output of every fired
36
+ * transition (skip arcs included); `consumed` counts one per input of every fired
37
+ * transition plus the final token.
38
+ */
39
+ const TABLE: {
40
+ name: string;
41
+ trace: string[];
42
+ counts: { produced: number; consumed: number; missing: number; remaining: number };
43
+ fitness: number;
44
+ deviations: Deviation[];
45
+ }[] = [
46
+ {
47
+ name: "perfect match",
48
+ trace: ["A", "B", "C", "D", "E"],
49
+ counts: { produced: 6, consumed: 6, missing: 0, remaining: 0 },
50
+ fitness: 1,
51
+ deviations: [],
52
+ },
53
+ {
54
+ name: "skips the optional step",
55
+ trace: ["A", "C", "D", "E"],
56
+ // The silent skip arc fires: +1 produced, +1 consumed, no penalty.
57
+ counts: { produced: 6, consumed: 6, missing: 0, remaining: 0 },
58
+ fitness: 1,
59
+ deviations: [],
60
+ },
61
+ {
62
+ name: "repeats the repeatable step",
63
+ trace: ["A", "B", "C", "C", "D", "E"],
64
+ counts: { produced: 7, consumed: 7, missing: 0, remaining: 0 },
65
+ fitness: 1,
66
+ deviations: [],
67
+ },
68
+ {
69
+ name: "skips a required step",
70
+ trace: ["A", "B", "C", "E"],
71
+ // E forces a token into p4; the token left in p3 remains. ½(1−1/5) + ½(1−1/5).
72
+ counts: { produced: 5, consumed: 5, missing: 1, remaining: 1 },
73
+ fitness: 0.8,
74
+ deviations: [{ type: "skipped", activity: "D", expected: "D", at: 3 }],
75
+ },
76
+ {
77
+ name: "wrong order",
78
+ trace: ["A", "B", "D", "C", "E"],
79
+ // D forces p3 (C is observed later, so it is not "skipped"); C then leaves p3 over.
80
+ // ½(1−1/6) + ½(1−1/6).
81
+ counts: { produced: 6, consumed: 6, missing: 1, remaining: 1 },
82
+ fitness: 5 / 6,
83
+ deviations: [{ type: "wrongOrder", activity: "D", expected: "C", at: 2 }],
84
+ },
85
+ {
86
+ name: "undesired activity",
87
+ trace: ["A", "B", "X", "C", "D", "E"],
88
+ // X is a phantom firing consuming one forced token. ½(1−1/7) + ½(1−0/6).
89
+ counts: { produced: 6, consumed: 7, missing: 1, remaining: 0 },
90
+ fitness: 13 / 14,
91
+ deviations: [{ type: "undesired", activity: "X", expected: "C", at: 2 }],
92
+ },
93
+ {
94
+ name: "incomplete",
95
+ trace: ["A", "B", "C"],
96
+ // Final place p5 never marked; p3 left over. ½(1−1/4) + ½(1−1/4).
97
+ counts: { produced: 4, consumed: 4, missing: 1, remaining: 1 },
98
+ fitness: 0.75,
99
+ deviations: [{ type: "incomplete", activity: "C", expected: "D", at: 3 }],
100
+ },
101
+ {
102
+ name: "wrong start",
103
+ trace: ["B", "C", "D", "E"],
104
+ // B forces p1; the initial token in p0 remains. ½(1−1/5) + ½(1−1/5).
105
+ counts: { produced: 5, consumed: 5, missing: 1, remaining: 1 },
106
+ fitness: 0.8,
107
+ deviations: [{ type: "wrongStart", activity: "B", expected: "A", at: 0 }],
108
+ },
109
+ ];
110
+
111
+ describe("replayTrace — hand-verified table", () => {
112
+ it.each(TABLE)("$name", ({ trace, counts, fitness, deviations }) => {
113
+ const result = replayTrace(rows("c", trace), model);
114
+ expect(result.caseId).toBe("c");
115
+ expect({
116
+ produced: result.produced,
117
+ consumed: result.consumed,
118
+ missing: result.missing,
119
+ remaining: result.remaining,
120
+ }).toEqual(counts);
121
+ expect(result.fitness).toBeCloseTo(fitness, 12);
122
+ expect(result.deviations).toEqual(deviations);
123
+ });
124
+ });
125
+
126
+ describe("replayTrace — input handling", () => {
127
+ it("orders rows in time and merges lifecycle pairs before replaying", () => {
128
+ const events: EventRow[] = [
129
+ { caseId: "c", activity: "E", timestamp: T0 + 9 },
130
+ { caseId: "c", activity: "B", timestamp: T0 + 3, lifecycle: "complete" },
131
+ { caseId: "c", activity: "A", timestamp: T0 + 1 },
132
+ { caseId: "c", activity: "B", timestamp: T0 + 2, lifecycle: "start" },
133
+ { caseId: "c", activity: "D", timestamp: T0 + 8 },
134
+ { caseId: "c", activity: "C", timestamp: T0 + 5 },
135
+ ];
136
+ const result = replayTrace(events, model);
137
+ expect(result.fitness).toBe(1);
138
+ expect(result.deviations).toEqual([]);
139
+ });
140
+
141
+ it("scores an empty trace as incomplete, never NaN", () => {
142
+ const result = replayActivities("empty", [], model);
143
+ expect(result.deviations).toEqual([{ type: "incomplete", expected: "A", at: 0 }]);
144
+ expect(result.fitness).toBe(0);
145
+ });
146
+
147
+ it("does not report an optional tail step as incomplete", () => {
148
+ const tail = liftHappyPath({
149
+ id: "t",
150
+ label: "Tail",
151
+ steps: [{ activity: "A" }, { activity: "Z", optional: true }],
152
+ });
153
+ const result = replayActivities("c", ["A"], tail);
154
+ expect(result.deviations).toEqual([]);
155
+ expect(result.fitness).toBe(1);
156
+ });
157
+ });
158
+
159
+ describe("tokenReplay", () => {
160
+ const log: EventLog = {
161
+ events: TABLE.flatMap(({ trace }, i) => rows(`case-${i}`, trace, T0 + i * 86_400_000)),
162
+ };
163
+ const result = tokenReplay(log, model);
164
+ const allDeviations = result.traces.flatMap((t) => t.deviations);
165
+
166
+ it("replays every case in log order", () => {
167
+ expect(result.traces.map((t) => t.caseId)).toEqual(TABLE.map((_, i) => `case-${i}`));
168
+ TABLE.forEach((row, i) => {
169
+ expect(result.traces[i]?.fitness).toBeCloseTo(row.fitness, 12);
170
+ });
171
+ });
172
+
173
+ it("reports overall fitness as the mean trace fitness", () => {
174
+ const mean = TABLE.reduce((sum, row) => sum + row.fitness, 0) / TABLE.length;
175
+ expect(result.overallFitness).toBeCloseTo(mean, 12);
176
+ });
177
+
178
+ it("keeps tallies non-negative and consistent with deviationCounts", () => {
179
+ expect(Object.keys(result.deviationCounts).sort()).toEqual([...DEVIATION_TYPES].sort());
180
+ expect(result.deviationCounts).toEqual({
181
+ undesired: 1,
182
+ skipped: 1,
183
+ wrongOrder: 1,
184
+ wrongStart: 1,
185
+ incomplete: 1,
186
+ });
187
+ const total = Object.values(result.deviationCounts).reduce((a, b) => a + b, 0);
188
+ expect(total).toBe(allDeviations.length);
189
+
190
+ const perActivity = Object.values(result.perActivity).map((v) => v.deviations);
191
+ const perEdge = Object.values(result.perEdge).map((v) => v.deviations);
192
+ for (const n of [...perActivity, ...perEdge]) expect(n).toBeGreaterThan(0);
193
+ expect(perActivity.reduce((a, b) => a + b, 0)).toBe(total);
194
+ expect(perEdge.reduce((a, b) => a + b, 0)).toBeLessThanOrEqual(total);
195
+ });
196
+
197
+ it("charges a deviation to the observed edge into the deviating event", () => {
198
+ const edge = (from: string, to: string) => `${from}${EDGE_KEY_SEPARATOR}${to}`;
199
+ expect({ ...result.perEdge }).toEqual({
200
+ [edge("C", "E")]: { deviations: 1 },
201
+ [edge("B", "D")]: { deviations: 1 },
202
+ [edge("B", "X")]: { deviations: 1 },
203
+ });
204
+ expect({ ...result.perActivity }).toEqual({
205
+ D: { deviations: 2 },
206
+ X: { deviations: 1 },
207
+ C: { deviations: 1 },
208
+ B: { deviations: 1 },
209
+ });
210
+ });
211
+
212
+ it("returns zero fitness and zeroed counts for an empty log", () => {
213
+ const empty = tokenReplay({ events: [] }, model);
214
+ expect(empty.overallFitness).toBe(0);
215
+ expect(empty.traces).toEqual([]);
216
+ expect(Object.values(empty.deviationCounts).every((n) => n === 0)).toBe(true);
217
+ });
218
+ });