@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,208 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { aggregatePerformance, DURATION_UNIT_MS, performanceValue } from "./aggregate-performance";
4
+ import { discoverGraph, EDGE_KEY_SEPARATOR } from "./discover-graph";
5
+ import { normalizeLog } from "./event-log";
6
+ import type { DurationStats, EventLog } from "./types";
7
+
8
+ const MINUTE = 60_000;
9
+
10
+ /**
11
+ * Two cases, atomic events, one edge A→B whose gaps are 1 and 3 minutes.
12
+ *
13
+ * Small enough that every expected number below is arithmetic anyone can redo: the edge's
14
+ * samples are [60000, 180000], so median 120000, min 60000, max 180000, sum 240000.
15
+ */
16
+ const twoGaps: EventLog = {
17
+ events: [
18
+ { caseId: "c1", activity: "A", timestamp: 0 },
19
+ { caseId: "c1", activity: "B", timestamp: MINUTE },
20
+ { caseId: "c2", activity: "A", timestamp: 0 },
21
+ { caseId: "c2", activity: "B", timestamp: 3 * MINUTE },
22
+ ],
23
+ };
24
+
25
+ /** One case of INTERVAL events, where waiting time and cycle time genuinely differ. */
26
+ const interval: EventLog = {
27
+ events: [
28
+ { caseId: "c1", activity: "A", startTimestamp: 0, timestamp: 30_000 },
29
+ { caseId: "c1", activity: "B", startTimestamp: MINUTE, timestamp: 90_000 },
30
+ ],
31
+ };
32
+
33
+ const edgeKey = (source: string, target: string): string =>
34
+ `${source}${EDGE_KEY_SEPARATOR}${target}`;
35
+
36
+ describe("performanceValue", () => {
37
+ const stats: DurationStats = {
38
+ min: 1,
39
+ max: 7,
40
+ mean: 3,
41
+ median: 2,
42
+ p90: 6,
43
+ sum: 12,
44
+ trimmedMean: 4,
45
+ };
46
+
47
+ it("maps every aggregate name to its own member", () => {
48
+ expect(performanceValue(stats, "min")).toBe(1);
49
+ expect(performanceValue(stats, "max")).toBe(7);
50
+ expect(performanceValue(stats, "mean")).toBe(3);
51
+ expect(performanceValue(stats, "median")).toBe(2);
52
+ expect(performanceValue(stats, "p90")).toBe(6);
53
+ expect(performanceValue(stats, "sum")).toBe(12);
54
+ // The snake_case wire spelling of `trimmedMean` — the mapping that is easy to get
55
+ // wrong and impossible to notice, because every value is a plausible duration.
56
+ expect(performanceValue(stats, "trimmed_mean")).toBe(4);
57
+ });
58
+ });
59
+
60
+ describe("aggregatePerformance unit conversion", () => {
61
+ const graph = discoverGraph(twoGaps);
62
+
63
+ it("converts every member of every DurationStats, not only the selected one", () => {
64
+ const layered = aggregatePerformance(graph, {
65
+ agg: "median",
66
+ flowTime: "idle_time",
67
+ unit: "min",
68
+ });
69
+ const edge = layered.transitions.find((t) => t.source === "A" && t.target === "B");
70
+ const duration = edge?.duration as DurationStats;
71
+ // Member by member, and with a tolerance: a unit conversion is a float multiply, so
72
+ // p90 lands on 2.8000000000000003 and asserting the decimal literal would be a test
73
+ // that fails for the wrong reason.
74
+ const expected: DurationStats = {
75
+ min: 1,
76
+ max: 3,
77
+ mean: 2,
78
+ median: 2,
79
+ p90: 2.8,
80
+ sum: 4,
81
+ trimmedMean: 2,
82
+ };
83
+ for (const key of Object.keys(expected) as (keyof DurationStats)[]) {
84
+ expect(duration[key]).toBeCloseTo(expected[key], 9);
85
+ }
86
+ });
87
+
88
+ it("exposes the selected aggregate per element, in the requested unit", () => {
89
+ for (const [unit, divisor] of Object.entries(DURATION_UNIT_MS)) {
90
+ const layered = aggregatePerformance(graph, {
91
+ agg: "max",
92
+ flowTime: "idle_time",
93
+ unit: unit as keyof typeof DURATION_UNIT_MS,
94
+ });
95
+ expect(layered.performance.transitions[edgeKey("A", "B")]).toBeCloseTo(
96
+ (3 * MINUTE) / divisor,
97
+ 9,
98
+ );
99
+ }
100
+ });
101
+
102
+ it("reports the domains a scale needs", () => {
103
+ const layered = aggregatePerformance(graph, {
104
+ agg: "median",
105
+ flowTime: "idle_time",
106
+ unit: "min",
107
+ });
108
+ expect(layered.performance.transitionDomain).toEqual([2, 2]);
109
+ // Atomic events have no execution time of their own, so every activity measures zero.
110
+ expect(layered.performance.activityDomain).toEqual([0, 0]);
111
+ });
112
+
113
+ it("echoes the settings it was driven with, so a legend can read them back", () => {
114
+ const layered = aggregatePerformance(graph, {
115
+ agg: "p90",
116
+ flowTime: "inter_start_time",
117
+ unit: "h",
118
+ });
119
+ expect(layered.performance.agg).toBe("p90");
120
+ expect(layered.performance.flowTime).toBe("inter_start_time");
121
+ expect(layered.performance.unit).toBe("h");
122
+ });
123
+
124
+ it("does not mutate the graph it was handed", () => {
125
+ const before = structuredClone(graph);
126
+ aggregatePerformance(graph, { agg: "sum", flowTime: "idle_time", unit: "s" });
127
+ expect(graph).toEqual(before);
128
+ });
129
+ });
130
+
131
+ describe("aggregatePerformance flow time", () => {
132
+ it("re-derives waiting time vs cycle time when it is given the log", () => {
133
+ const graph = discoverGraph(interval); // discovered at the default idle_time
134
+ const idle = aggregatePerformance(graph, {
135
+ agg: "median",
136
+ flowTime: "idle_time",
137
+ unit: "ms",
138
+ log: interval,
139
+ });
140
+ const cycle = aggregatePerformance(graph, {
141
+ agg: "median",
142
+ flowTime: "inter_start_time",
143
+ unit: "ms",
144
+ log: interval,
145
+ });
146
+ // A ends at 30s and B starts at 60s → 30s of waiting; A starts at 0 → 60s of cycle.
147
+ expect(idle.performance.transitions[edgeKey("A", "B")]).toBe(30_000);
148
+ expect(cycle.performance.transitions[edgeKey("A", "B")]).toBe(60_000);
149
+ });
150
+
151
+ it("accepts an already-normalized log, and answers identically", () => {
152
+ const graph = discoverGraph(interval);
153
+ const fromRaw = aggregatePerformance(graph, {
154
+ agg: "median",
155
+ flowTime: "inter_start_time",
156
+ unit: "s",
157
+ log: interval,
158
+ });
159
+ const fromNormalized = aggregatePerformance(graph, {
160
+ agg: "median",
161
+ flowTime: "inter_start_time",
162
+ unit: "s",
163
+ log: normalizeLog(interval),
164
+ });
165
+ expect(fromNormalized).toEqual(fromRaw);
166
+ });
167
+
168
+ it("without a log, carries the graph's own samples through unchanged", () => {
169
+ const graph = discoverGraph(interval);
170
+ const layered = aggregatePerformance(graph, {
171
+ agg: "median",
172
+ flowTime: "inter_start_time",
173
+ unit: "ms",
174
+ });
175
+ // `flowTime` is a declaration here, not an instruction: the graph was discovered at
176
+ // idle_time, so 30s is what it still measures. This is the documented limitation.
177
+ expect(layered.performance.transitions[edgeKey("A", "B")]).toBe(30_000);
178
+ });
179
+
180
+ it("keeps a reduced graph's numbers derived from the FULL log", () => {
181
+ // The "sliders never change statistics" rule, one layer up: a performance layer over
182
+ // an abstracted graph must still describe everything that happened.
183
+ const log: EventLog = {
184
+ events: [
185
+ { caseId: "c1", activity: "A", timestamp: 0 },
186
+ { caseId: "c1", activity: "B", timestamp: MINUTE },
187
+ { caseId: "c2", activity: "A", timestamp: 0 },
188
+ { caseId: "c2", activity: "B", timestamp: 3 * MINUTE },
189
+ { caseId: "c3", activity: "A", timestamp: 0 },
190
+ { caseId: "c3", activity: "Rare", timestamp: 10 * MINUTE },
191
+ ],
192
+ };
193
+ const full = discoverGraph(log);
194
+ const reduced = {
195
+ ...full,
196
+ activities: full.activities.filter((a) => a.id !== "Rare"),
197
+ transitions: full.transitions.filter((t) => t.target !== "Rare"),
198
+ };
199
+ const layered = aggregatePerformance(reduced, {
200
+ agg: "median",
201
+ flowTime: "idle_time",
202
+ unit: "min",
203
+ log,
204
+ });
205
+ expect(layered.performance.transitions[edgeKey("A", "B")]).toBe(2);
206
+ expect(layered.activities.map((a) => a.id)).not.toContain("Rare");
207
+ });
208
+ });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The performance metric layer — RM-050.
3
+ *
4
+ * A process map is read twice: once for FREQUENCY (how often does this happen) and once
5
+ * for PERFORMANCE (how long does it take). The frequency numbers come straight off
6
+ * discovery; the performance numbers need three further decisions that only a reader can
7
+ * make — which aggregate (median, mean, p90 …), which elapsed time an edge measures
8
+ * (waiting or cycle), and which unit the answer is spoken in. This module applies all
9
+ * three to an already-discovered graph and hands back the scalar each node and edge
10
+ * should be painted with.
11
+ *
12
+ * The returned graph is a NEW object graph: unlike {@link abstractGraph}, which filters,
13
+ * this function re-expresses. The input is never mutated.
14
+ */
15
+ import { discoverGraph } from "./discover-graph";
16
+ import { EDGE_KEY_SEPARATOR } from "./discover-graph";
17
+ import type { AnyLog } from "./event-log";
18
+ import { minMax } from "./scale";
19
+ import type {
20
+ ActivityStats,
21
+ DurationStats,
22
+ FlowTime,
23
+ PerformanceAgg,
24
+ ProcessGraph,
25
+ TransitionStats,
26
+ } from "./types";
27
+
28
+ /** The units a duration can be spoken in. Everything upstream is milliseconds. */
29
+ export type DurationUnit = "ms" | "s" | "min" | "h" | "d";
30
+
31
+ /** How many milliseconds each {@link DurationUnit} is worth. */
32
+ export const DURATION_UNIT_MS: Readonly<Record<DurationUnit, number>> = Object.freeze({
33
+ ms: 1,
34
+ s: 1_000,
35
+ min: 60_000,
36
+ h: 3_600_000,
37
+ d: 86_400_000,
38
+ });
39
+
40
+ /** Options for {@link aggregatePerformance}. */
41
+ export interface PerformanceOptions {
42
+ /** Which member of a {@link DurationStats} the layer reads. */
43
+ agg: PerformanceAgg;
44
+ /**
45
+ * Which elapsed time an edge measures. Honoured only when `log` is supplied — a graph
46
+ * alone cannot be converted from waiting time to cycle time, because the two are not
47
+ * derivable from each other's summary statistics. Without `log` this field DECLARES
48
+ * what the graph already carries and nothing is re-derived.
49
+ */
50
+ flowTime: FlowTime;
51
+ /** The unit every returned duration is expressed in. */
52
+ unit: DurationUnit;
53
+ /**
54
+ * The log the graph came from. Supply it to re-derive edge durations at `flowTime`;
55
+ * pass an already-normalized log (see `asNormalizedLog`) to skip a second parse.
56
+ */
57
+ log?: AnyLog;
58
+ /**
59
+ * Reservoir capacity per activity and per edge when re-deriving. Order statistics
60
+ * (`median`, `p90`, `trimmedMean`) are ESTIMATES past this many samples per key; raise
61
+ * it when a threshold has to be exact. Only meaningful together with `log`.
62
+ */
63
+ maxDurationSamples?: number;
64
+ }
65
+
66
+ /** The per-element scalars a performance view paints with, plus the domains to scale them on. */
67
+ export interface PerformanceLayer {
68
+ agg: PerformanceAgg;
69
+ flowTime: FlowTime;
70
+ unit: DurationUnit;
71
+ /** Activity id → the selected aggregate of its execution time, in `unit`. */
72
+ activities: Record<string, number>;
73
+ /** `source␁target` (see `EDGE_KEY_SEPARATOR`) → the selected aggregate, in `unit`. */
74
+ transitions: Record<string, number>;
75
+ /** `[min, max]` over `activities` — `[0, 0]` when there are none. */
76
+ activityDomain: [number, number];
77
+ /** `[min, max]` over `transitions` — `[0, 0]` when there are none. */
78
+ transitionDomain: [number, number];
79
+ }
80
+
81
+ /** What {@link aggregatePerformance} returns: a `ProcessGraph` plus its metric layer. */
82
+ export interface PerformanceGraph extends ProcessGraph {
83
+ performance: PerformanceLayer;
84
+ }
85
+
86
+ /**
87
+ * Read the member of `stats` a {@link PerformanceAgg} names.
88
+ *
89
+ * The one place the mapping lives, so a control, a legend and a renderer cannot disagree
90
+ * about what "p90" is. `trimmed_mean` is the snake_case spelling of `trimmedMean` —
91
+ * the type is a wire/URL value, the field is a property name.
92
+ */
93
+ export function performanceValue(stats: DurationStats, agg: PerformanceAgg): number {
94
+ switch (agg) {
95
+ case "mean":
96
+ return stats.mean;
97
+ case "min":
98
+ return stats.min;
99
+ case "max":
100
+ return stats.max;
101
+ case "sum":
102
+ return stats.sum;
103
+ case "p90":
104
+ return stats.p90;
105
+ case "trimmed_mean":
106
+ return stats.trimmedMean;
107
+ case "median":
108
+ default:
109
+ return stats.median;
110
+ }
111
+ }
112
+
113
+ /** Scale every member of `stats` by `factor`. Returns a new object; `stats` is untouched. */
114
+ function scaleStats(stats: DurationStats, factor: number): DurationStats {
115
+ if (factor === 1) return { ...stats };
116
+ return {
117
+ min: stats.min * factor,
118
+ max: stats.max * factor,
119
+ mean: stats.mean * factor,
120
+ median: stats.median * factor,
121
+ p90: stats.p90 * factor,
122
+ sum: stats.sum * factor,
123
+ trimmedMean: stats.trimmedMean * factor,
124
+ };
125
+ }
126
+
127
+ function edgeKey(source: string, target: string): string {
128
+ return `${source}${EDGE_KEY_SEPARATOR}${target}`;
129
+ }
130
+
131
+ /**
132
+ * Express `graph`'s durations as a performance layer.
133
+ *
134
+ * Every `DurationStats` in the returned graph is converted to `opts.unit`, and
135
+ * `performance` carries the single scalar per activity and per transition that
136
+ * `opts.agg` selects, together with the two domains a scale needs.
137
+ *
138
+ * With `opts.log`, edge and activity durations are RE-DERIVED at `opts.flowTime` from the
139
+ * FULL log — which is what keeps a performance layer honest on an abstracted graph: the
140
+ * numbers describe everything that happened, not only the part currently drawn. Elements
141
+ * the re-derivation does not know about keep the statistics they arrived with.
142
+ *
143
+ * Order statistics past the reservoir cap are estimates (see `maxDurationSamples`);
144
+ * `sum`, `mean`, `min` and `max` are exact at any log size.
145
+ */
146
+ export function aggregatePerformance(
147
+ graph: ProcessGraph,
148
+ opts: PerformanceOptions,
149
+ ): PerformanceGraph {
150
+ const factor = 1 / (DURATION_UNIT_MS[opts.unit] ?? 1);
151
+
152
+ let activitySource: Map<string, DurationStats> | undefined;
153
+ let transitionSource: Map<string, DurationStats> | undefined;
154
+ if (opts.log !== undefined) {
155
+ const derived = discoverGraph(opts.log, {
156
+ flowTime: opts.flowTime,
157
+ ...(opts.maxDurationSamples === undefined
158
+ ? {}
159
+ : { maxDurationSamples: opts.maxDurationSamples }),
160
+ });
161
+ activitySource = new Map(derived.activities.map((a) => [a.id, a.duration]));
162
+ transitionSource = new Map(
163
+ derived.transitions.map((t) => [edgeKey(t.source, t.target), t.duration]),
164
+ );
165
+ }
166
+
167
+ const activityValues: Record<string, number> = {};
168
+ const activities: ActivityStats[] = graph.activities.map((activity) => {
169
+ const source = activitySource?.get(activity.id) ?? activity.duration;
170
+ const duration = scaleStats(source, factor);
171
+ activityValues[activity.id] = performanceValue(duration, opts.agg);
172
+ return { ...activity, duration };
173
+ });
174
+
175
+ const transitionValues: Record<string, number> = {};
176
+ const transitions: TransitionStats[] = graph.transitions.map((edge) => {
177
+ const key = edgeKey(edge.source, edge.target);
178
+ const source = transitionSource?.get(key) ?? edge.duration;
179
+ const duration = scaleStats(source, factor);
180
+ transitionValues[key] = performanceValue(duration, opts.agg);
181
+ return { ...edge, duration };
182
+ });
183
+
184
+ return {
185
+ activities,
186
+ transitions,
187
+ startActivities: { ...graph.startActivities },
188
+ endActivities: { ...graph.endActivities },
189
+ totals: { ...graph.totals },
190
+ performance: {
191
+ agg: opts.agg,
192
+ flowTime: opts.flowTime,
193
+ unit: opts.unit,
194
+ activities: activityValues,
195
+ transitions: transitionValues,
196
+ activityDomain: minMax(Object.values(activityValues)),
197
+ transitionDomain: minMax(Object.values(transitionValues)),
198
+ },
199
+ };
200
+ }
@@ -0,0 +1,134 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { detectRework } from "./detect-rework";
4
+ import { normalizeLog } from "./event-log";
5
+ import fixture from "./fixtures/order-to-cash-small.json";
6
+ import { generateSyntheticLog } from "./fixtures/synthetic-log";
7
+ import type { EventLog } from "./types";
8
+
9
+ const orderToCash = fixture as EventLog;
10
+
11
+ /**
12
+ * The five traces in `order-to-cash-small.json`, restated so the numbers below can be
13
+ * read against them without opening the fixture:
14
+ *
15
+ * case-1 Create Order · Check Credit · Approve Order · Ship Order · Send Invoice · Receive Payment
16
+ * case-2 Create Order · Check Credit · Approve Order · Ship Order · Send Invoice · Receive Payment
17
+ * case-3 Create Order · Check Credit · Reject Order
18
+ * case-4 Create Order · Check Credit · Amend Order · Check Credit · Approve Order · Ship Order ·
19
+ * Send Invoice · Receive Payment
20
+ * case-5 Create Order · Check Credit · Approve Order · Send Invoice · Ship Order · Receive Payment
21
+ *
22
+ * Exactly one repeat in the whole fixture: case-4's second Check Credit, with Amend Order
23
+ * in between — a LOOP, not a self-loop. Counted by hand, not read off the implementation.
24
+ */
25
+ describe("detectRework on the 5-case order-to-cash fixture", () => {
26
+ it("finds the one non-adjacent repeat and no adjacent ones", () => {
27
+ const rework = detectRework(orderToCash);
28
+ expect(rework.selfLoops).toBe(0);
29
+ expect(rework.loops).toBe(1);
30
+ expect(rework.caseReworkRate).toBe(1 / 5);
31
+ });
32
+
33
+ it("attributes the loop to Check Credit and lists every other activity at zero", () => {
34
+ expect(detectRework(orderToCash).perActivity).toEqual({
35
+ "Amend Order": { selfLoops: 0, loops: 0 },
36
+ "Approve Order": { selfLoops: 0, loops: 0 },
37
+ "Check Credit": { selfLoops: 0, loops: 1 },
38
+ "Create Order": { selfLoops: 0, loops: 0 },
39
+ "Receive Payment": { selfLoops: 0, loops: 0 },
40
+ "Reject Order": { selfLoops: 0, loops: 0 },
41
+ "Send Invoice": { selfLoops: 0, loops: 0 },
42
+ "Ship Order": { selfLoops: 0, loops: 0 },
43
+ });
44
+ });
45
+
46
+ it("answers identically from an already-normalized log", () => {
47
+ expect(detectRework(normalizeLog(orderToCash))).toEqual(detectRework(orderToCash));
48
+ });
49
+ });
50
+
51
+ describe("detectRework classification", () => {
52
+ const traceLog = (activities: readonly string[][]): EventLog => ({
53
+ events: activities.flatMap((trace, caseIndex) =>
54
+ trace.map((activity, step) => ({
55
+ caseId: `case-${caseIndex}`,
56
+ activity,
57
+ timestamp: step * 1000,
58
+ })),
59
+ ),
60
+ });
61
+
62
+ it("counts an adjacent repeat as a self-loop", () => {
63
+ const rework = detectRework(traceLog([["A", "A", "B"]]));
64
+ expect(rework.selfLoops).toBe(1);
65
+ expect(rework.loops).toBe(0);
66
+ expect(rework.perActivity["A"]).toEqual({ selfLoops: 1, loops: 0 });
67
+ });
68
+
69
+ it("counts a return after another activity as a loop", () => {
70
+ const rework = detectRework(traceLog([["A", "B", "A"]]));
71
+ expect(rework.selfLoops).toBe(0);
72
+ expect(rework.loops).toBe(1);
73
+ });
74
+
75
+ it("splits a trace that does both, and never double-counts an occurrence", () => {
76
+ // A B A A B — occurrences beyond the first: the third A (loop), the fourth A
77
+ // (self-loop), the fifth B (loop). Three repeats, three tallies, no more.
78
+ const rework = detectRework(traceLog([["A", "B", "A", "A", "B"]]));
79
+ expect(rework.selfLoops).toBe(1);
80
+ expect(rework.loops).toBe(2);
81
+ expect(rework.perActivity["A"]).toEqual({ selfLoops: 1, loops: 1 });
82
+ expect(rework.perActivity["B"]).toEqual({ selfLoops: 0, loops: 1 });
83
+ });
84
+
85
+ it("counts a case once however much rework it carries", () => {
86
+ const rework = detectRework(
87
+ traceLog([
88
+ ["A", "A", "A", "A"],
89
+ ["B", "C"],
90
+ ]),
91
+ );
92
+ expect(rework.selfLoops).toBe(3);
93
+ expect(rework.caseReworkRate).toBe(1 / 2);
94
+ });
95
+
96
+ it("answers zeros for an empty log rather than dividing by zero", () => {
97
+ expect(detectRework({ events: [] })).toEqual({
98
+ selfLoops: 0,
99
+ loops: 0,
100
+ caseReworkRate: 0,
101
+ perActivity: {},
102
+ });
103
+ });
104
+
105
+ it("lists activities in ascending name order", () => {
106
+ const rework = detectRework(traceLog([["Zeta", "Alpha", "Mu"]]));
107
+ expect(Object.keys(rework.perActivity)).toEqual(["Alpha", "Mu", "Zeta"]);
108
+ });
109
+ });
110
+
111
+ describe("detectRework on the synthetic log", () => {
112
+ it("adds up: every repeat is exactly one of the two kinds", () => {
113
+ const log = generateSyntheticLog({ cases: 200, seed: 11 });
114
+ const normalized = normalizeLog(log);
115
+ const rework = detectRework(normalized);
116
+
117
+ // Counted independently: total executions minus distinct activities per case.
118
+ let repeats = 0;
119
+ for (const kase of normalized.cases) {
120
+ const distinct = new Set(kase.events.map((event) => event.activity));
121
+ repeats += kase.events.length - distinct.size;
122
+ }
123
+ expect(rework.selfLoops + rework.loops).toBe(repeats);
124
+ expect(repeats).toBeGreaterThan(0);
125
+ expect(rework.caseReworkRate).toBeGreaterThan(0);
126
+ expect(rework.caseReworkRate).toBeLessThanOrEqual(1);
127
+ });
128
+
129
+ it("is deterministic for a given seed", () => {
130
+ const first = detectRework(generateSyntheticLog({ cases: 50, seed: 3 }));
131
+ const second = detectRework(generateSyntheticLog({ cases: 50, seed: 3 }));
132
+ expect(second).toEqual(first);
133
+ });
134
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Rework detection — RM-050.
3
+ *
4
+ * "Rework" is the part of a process that happens more than once inside a single case: a
5
+ * step retried immediately (a SELF-LOOP), or a step the case comes back to after going
6
+ * somewhere else (a LOOP). Both are repeats, and separating them matters — a self-loop is
7
+ * usually a retry or a batch, a loop is usually a rejection sending work backwards, and
8
+ * the two lead to different conversations about the process.
9
+ *
10
+ * Counted per OCCURRENCE, over the normalized trace: the first execution of an activity in
11
+ * a case is never rework, and every later one is exactly one of the two kinds. So
12
+ * `selfLoops + loops` is precisely the number of repeated executions in the log.
13
+ *
14
+ * Deterministic and framework-free, like everything else in `/core`.
15
+ */
16
+ import { asNormalizedLog, type AnyLog } from "./event-log";
17
+
18
+ /** Per-activity rework tallies. */
19
+ export interface ActivityRework {
20
+ /** Repeats of this activity immediately after itself. */
21
+ selfLoops: number;
22
+ /** Repeats of this activity later in the same case, with something else in between. */
23
+ loops: number;
24
+ }
25
+
26
+ /** What {@link detectRework} answers. */
27
+ export interface ReworkStats {
28
+ /** Adjacent repeats across the whole log. */
29
+ selfLoops: number;
30
+ /** Non-adjacent repeats across the whole log. */
31
+ loops: number;
32
+ /** Fraction of cases carrying at least one self-loop or loop. `0` for an empty log. */
33
+ caseReworkRate: number;
34
+ /**
35
+ * Every activity in the log, in ascending name order, with its own two tallies. An
36
+ * activity that never repeats is present with zeros — a renderer joining this against
37
+ * a graph's activities never has to distinguish "no rework" from "unknown activity".
38
+ */
39
+ perActivity: Record<string, ActivityRework>;
40
+ }
41
+
42
+ /**
43
+ * Count rework in `log`.
44
+ *
45
+ * Accepts a raw or an already-normalized log and normalizes at most once
46
+ * ({@link asNormalizedLog} is idempotent), so a caller that has already normalized for
47
+ * `discoverGraph` pays nothing here.
48
+ */
49
+ export function detectRework(log: AnyLog): ReworkStats {
50
+ const normalized = asNormalizedLog(log);
51
+
52
+ const tallies = new Map<string, ActivityRework>();
53
+ let selfLoops = 0;
54
+ let loops = 0;
55
+ let casesWithRework = 0;
56
+
57
+ const seen = new Set<string>();
58
+ for (const kase of normalized.cases) {
59
+ seen.clear();
60
+ let caseHasRework = false;
61
+ let previous: string | undefined;
62
+
63
+ for (const event of kase.events) {
64
+ const name = event.activity;
65
+ let tally = tallies.get(name);
66
+ if (tally === undefined) {
67
+ tally = { selfLoops: 0, loops: 0 };
68
+ tallies.set(name, tally);
69
+ }
70
+
71
+ if (seen.has(name)) {
72
+ caseHasRework = true;
73
+ if (name === previous) {
74
+ tally.selfLoops += 1;
75
+ selfLoops += 1;
76
+ } else {
77
+ tally.loops += 1;
78
+ loops += 1;
79
+ }
80
+ } else {
81
+ seen.add(name);
82
+ }
83
+ previous = name;
84
+ }
85
+
86
+ if (caseHasRework) casesWithRework += 1;
87
+ }
88
+
89
+ const perActivity: Record<string, ActivityRework> = {};
90
+ for (const name of [...tallies.keys()].sort()) {
91
+ perActivity[name] = tallies.get(name) as ActivityRework;
92
+ }
93
+
94
+ return {
95
+ selfLoops,
96
+ loops,
97
+ caseReworkRate: normalized.cases.length === 0 ? 0 : casesWithRework / normalized.cases.length,
98
+ perActivity,
99
+ };
100
+ }