@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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duration summary statistics — RM-049.
|
|
3
|
+
*
|
|
4
|
+
* The single implementation every part of the core reads: per-activity execution time,
|
|
5
|
+
* per-edge flow time and per-variant case time all pass through here, which is what makes
|
|
6
|
+
* "the variant's median agrees with the edge's median on the same fixture" true by
|
|
7
|
+
* construction rather than by coincidence.
|
|
8
|
+
*
|
|
9
|
+
* Algorithm shape (which statistics a discovered graph carries per activity and per edge)
|
|
10
|
+
* follows pm4js — see `ATTRIBUTION.md`. No pm4js code is copied; the arithmetic below is
|
|
11
|
+
* the ordinary textbook definition.
|
|
12
|
+
*/
|
|
13
|
+
import { ascending, quantileSorted } from "./scale";
|
|
14
|
+
import type { DurationStats } from "./types";
|
|
15
|
+
|
|
16
|
+
/** The fraction discarded from EACH tail by `trimmedMean`. */
|
|
17
|
+
export const TRIM_FRACTION = 0.1;
|
|
18
|
+
|
|
19
|
+
/** All-zero statistics — the answer for an empty sample set. */
|
|
20
|
+
export const EMPTY_DURATION_STATS: Readonly<DurationStats> = Object.freeze({
|
|
21
|
+
min: 0,
|
|
22
|
+
max: 0,
|
|
23
|
+
mean: 0,
|
|
24
|
+
median: 0,
|
|
25
|
+
p90: 0,
|
|
26
|
+
sum: 0,
|
|
27
|
+
trimmedMean: 0,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/** A fresh mutable copy of {@link EMPTY_DURATION_STATS}. */
|
|
31
|
+
export function emptyDurationStats(): DurationStats {
|
|
32
|
+
return { ...EMPTY_DURATION_STATS };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Summarize `samples` (milliseconds) as {@link DurationStats}.
|
|
37
|
+
*
|
|
38
|
+
* - Non-finite samples are DROPPED, not propagated — one unparseable timestamp upstream
|
|
39
|
+
* must not turn an entire activity's statistics into `NaN`.
|
|
40
|
+
* - The input array is not mutated (a copy is sorted).
|
|
41
|
+
* - `p90` uses linear interpolation between order statistics (R-7 / `d3.quantile`).
|
|
42
|
+
* - `trimmedMean` discards the lowest and highest {@link TRIM_FRACTION} of samples. When
|
|
43
|
+
* trimming would leave nothing (fewer than 5 samples, where `floor(n * 0.1)` is 0 or
|
|
44
|
+
* the tails meet), it degrades to the plain mean rather than to `NaN`.
|
|
45
|
+
* - An empty (or all-non-finite) input answers all zeros.
|
|
46
|
+
*/
|
|
47
|
+
export function durationStats(samples: readonly number[]): DurationStats {
|
|
48
|
+
const sorted: number[] = [];
|
|
49
|
+
for (const s of samples) if (Number.isFinite(s)) sorted.push(s);
|
|
50
|
+
const n = sorted.length;
|
|
51
|
+
if (n === 0) return emptyDurationStats();
|
|
52
|
+
sorted.sort(ascending);
|
|
53
|
+
|
|
54
|
+
let sum = 0;
|
|
55
|
+
for (const s of sorted) sum += s;
|
|
56
|
+
|
|
57
|
+
const trim = Math.floor(n * TRIM_FRACTION);
|
|
58
|
+
const lo = trim;
|
|
59
|
+
const hi = n - trim;
|
|
60
|
+
let trimmedMean: number;
|
|
61
|
+
if (hi - lo <= 0) {
|
|
62
|
+
trimmedMean = sum / n;
|
|
63
|
+
} else {
|
|
64
|
+
let trimmedSum = 0;
|
|
65
|
+
for (let i = lo; i < hi; i += 1) trimmedSum += sorted[i] as number;
|
|
66
|
+
trimmedMean = trimmedSum / (hi - lo);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
min: sorted[0] as number,
|
|
71
|
+
max: sorted[n - 1] as number,
|
|
72
|
+
mean: sum / n,
|
|
73
|
+
median: quantileSorted(sorted, 0.5),
|
|
74
|
+
p90: quantileSorted(sorted, 0.9),
|
|
75
|
+
sum,
|
|
76
|
+
trimmedMean,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Default reservoir capacity for a single activity's or edge's duration samples. */
|
|
81
|
+
export const DURATION_SAMPLE_CAP = 4096;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A deterministic 32-bit PRNG (mulberry32). Same seed, same stream — which is the whole
|
|
85
|
+
* point: the reservoir below must make identical keep/discard decisions on every run, in
|
|
86
|
+
* every engine, or two runs over one log would produce different `p90`s.
|
|
87
|
+
*/
|
|
88
|
+
function mulberry32(seed: number): () => number {
|
|
89
|
+
let a = seed >>> 0;
|
|
90
|
+
return () => {
|
|
91
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
92
|
+
let t = a;
|
|
93
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
94
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
95
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Bounded duration-sample collector.
|
|
101
|
+
*
|
|
102
|
+
* A log with a million events would otherwise hold a million numbers per hot edge just to
|
|
103
|
+
* compute seven statistics from them. The first {@link DURATION_SAMPLE_CAP} samples are
|
|
104
|
+
* kept outright; past the cap the collector switches to reservoir sampling (Algorithm R),
|
|
105
|
+
* so the retained set stays a uniform sample of everything seen while memory stays flat.
|
|
106
|
+
*
|
|
107
|
+
* `count` keeps the TRUE number of samples offered, and `sum` the true total, so a
|
|
108
|
+
* capped edge still reports an exact `sum` and an exact `mean`; only the order statistics
|
|
109
|
+
* (`median`, `p90`, `min`, `max`, `trimmedMean`) are estimated from the reservoir.
|
|
110
|
+
*/
|
|
111
|
+
export class DurationSampler {
|
|
112
|
+
private readonly capacity: number;
|
|
113
|
+
private readonly random: () => number;
|
|
114
|
+
private readonly reservoir: number[] = [];
|
|
115
|
+
/** Number of finite samples offered, including those the reservoir discarded. */
|
|
116
|
+
private seen = 0;
|
|
117
|
+
private total = 0;
|
|
118
|
+
private lowest = Number.POSITIVE_INFINITY;
|
|
119
|
+
private highest = Number.NEGATIVE_INFINITY;
|
|
120
|
+
|
|
121
|
+
constructor(seed = 0x9e3779b9, capacity: number = DURATION_SAMPLE_CAP) {
|
|
122
|
+
this.capacity = capacity > 0 ? capacity : 1;
|
|
123
|
+
this.random = mulberry32(seed);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Offer one sample. Non-finite values are ignored. */
|
|
127
|
+
add(sample: number): void {
|
|
128
|
+
if (!Number.isFinite(sample)) return;
|
|
129
|
+
this.total += sample;
|
|
130
|
+
if (sample < this.lowest) this.lowest = sample;
|
|
131
|
+
if (sample > this.highest) this.highest = sample;
|
|
132
|
+
if (this.reservoir.length < this.capacity) {
|
|
133
|
+
this.reservoir.push(sample);
|
|
134
|
+
} else {
|
|
135
|
+
const j = Math.floor(this.random() * (this.seen + 1));
|
|
136
|
+
if (j < this.capacity) this.reservoir[j] = sample;
|
|
137
|
+
}
|
|
138
|
+
this.seen += 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** True number of finite samples offered. */
|
|
142
|
+
get size(): number {
|
|
143
|
+
return this.seen;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Summarize what was collected. `sum`, `mean`, `min` and `max` are EXACT even past the
|
|
148
|
+
* cap (they are accumulated, not read off the reservoir); the remaining three are
|
|
149
|
+
* computed from the retained sample.
|
|
150
|
+
*/
|
|
151
|
+
stats(): DurationStats {
|
|
152
|
+
if (this.seen === 0) return emptyDurationStats();
|
|
153
|
+
const fromReservoir = durationStats(this.reservoir);
|
|
154
|
+
return {
|
|
155
|
+
...fromReservoir,
|
|
156
|
+
min: this.lowest,
|
|
157
|
+
max: this.highest,
|
|
158
|
+
sum: this.total,
|
|
159
|
+
mean: this.total / this.seen,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { asNormalizedLog, isNormalizedLog, normalizeLog, toEpochMs } from "./event-log";
|
|
4
|
+
import type { EventLog } from "./types";
|
|
5
|
+
|
|
6
|
+
const T = (iso: string): number => Date.parse(iso);
|
|
7
|
+
|
|
8
|
+
describe("toEpochMs", () => {
|
|
9
|
+
it("resolves all three accepted timestamp encodings", () => {
|
|
10
|
+
expect(toEpochMs("2026-01-05T09:00:00.000Z")).toBe(T("2026-01-05T09:00:00.000Z"));
|
|
11
|
+
expect(toEpochMs(1_767_600_000_000)).toBe(1_767_600_000_000);
|
|
12
|
+
expect(toEpochMs(new Date("2026-01-05T09:00:00.000Z"))).toBe(T("2026-01-05T09:00:00.000Z"));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("reads a bare numeric string as epoch milliseconds", () => {
|
|
16
|
+
expect(toEpochMs("1767600000000")).toBe(1_767_600_000_000);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("answers NaN for an unparseable or absent value instead of throwing", () => {
|
|
20
|
+
expect(toEpochMs("not a date")).toBeNaN();
|
|
21
|
+
expect(toEpochMs(undefined)).toBeNaN();
|
|
22
|
+
expect(toEpochMs(null)).toBeNaN();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("normalizeLog", () => {
|
|
27
|
+
it("groups by case and orders each case in time without sorting the whole log", () => {
|
|
28
|
+
const log: EventLog = {
|
|
29
|
+
events: [
|
|
30
|
+
{ caseId: "b", activity: "B2", timestamp: "2026-01-05T11:00:00Z" },
|
|
31
|
+
{ caseId: "a", activity: "A2", timestamp: "2026-01-05T10:00:00Z" },
|
|
32
|
+
{ caseId: "b", activity: "B1", timestamp: "2026-01-05T09:00:00Z" },
|
|
33
|
+
{ caseId: "a", activity: "A1", timestamp: "2026-01-05T09:30:00Z" },
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
const normalized = normalizeLog(log);
|
|
37
|
+
// Cases keep FIRST-APPEARANCE order; events inside each case are sorted.
|
|
38
|
+
expect(normalized.cases.map((c) => c.caseId)).toEqual(["b", "a"]);
|
|
39
|
+
expect(normalized.cases[0]?.events.map((e) => e.activity)).toEqual(["B1", "B2"]);
|
|
40
|
+
expect(normalized.cases[1]?.events.map((e) => e.activity)).toEqual(["A1", "A2"]);
|
|
41
|
+
expect(normalized.totals).toEqual({ cases: 2, events: 4 });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("pairs start/complete rows that are NOT adjacent in the input", () => {
|
|
45
|
+
// Interleaved lifecycle rows, and the rows themselves arrive out of time order.
|
|
46
|
+
const log: EventLog = {
|
|
47
|
+
events: [
|
|
48
|
+
{
|
|
49
|
+
caseId: "c1",
|
|
50
|
+
activity: "Pack",
|
|
51
|
+
lifecycle: "complete",
|
|
52
|
+
timestamp: "2026-01-05T12:00:00Z",
|
|
53
|
+
},
|
|
54
|
+
{ caseId: "c1", activity: "Pick", lifecycle: "start", timestamp: "2026-01-05T09:00:00Z" },
|
|
55
|
+
{ caseId: "c1", activity: "Pack", lifecycle: "start", timestamp: "2026-01-05T10:00:00Z" },
|
|
56
|
+
{
|
|
57
|
+
caseId: "c1",
|
|
58
|
+
activity: "Pick",
|
|
59
|
+
lifecycle: "complete",
|
|
60
|
+
timestamp: "2026-01-05T11:00:00Z",
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
const [kase] = normalizeLog(log).cases;
|
|
65
|
+
expect(kase?.events).toEqual([
|
|
66
|
+
{
|
|
67
|
+
activity: "Pick",
|
|
68
|
+
start: T("2026-01-05T09:00:00Z"),
|
|
69
|
+
end: T("2026-01-05T11:00:00Z"),
|
|
70
|
+
duration: 7_200_000,
|
|
71
|
+
isOpen: false,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
activity: "Pack",
|
|
75
|
+
start: T("2026-01-05T10:00:00Z"),
|
|
76
|
+
end: T("2026-01-05T12:00:00Z"),
|
|
77
|
+
duration: 7_200_000,
|
|
78
|
+
isOpen: false,
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
expect(kase?.duration).toBe(10_800_000);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("pairs repeated executions of one activity oldest-start to earliest-complete", () => {
|
|
85
|
+
const log: EventLog = {
|
|
86
|
+
events: [
|
|
87
|
+
{ caseId: "c1", activity: "Pick", lifecycle: "start", timestamp: "2026-01-05T09:00:00Z" },
|
|
88
|
+
{ caseId: "c1", activity: "Pick", lifecycle: "start", timestamp: "2026-01-05T09:30:00Z" },
|
|
89
|
+
{
|
|
90
|
+
caseId: "c1",
|
|
91
|
+
activity: "Pick",
|
|
92
|
+
lifecycle: "complete",
|
|
93
|
+
timestamp: "2026-01-05T10:00:00Z",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
caseId: "c1",
|
|
97
|
+
activity: "Pick",
|
|
98
|
+
lifecycle: "complete",
|
|
99
|
+
timestamp: "2026-01-05T11:00:00Z",
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
const events = normalizeLog(log).cases[0]?.events ?? [];
|
|
104
|
+
expect(events.map((e) => [e.start, e.end])).toEqual([
|
|
105
|
+
[T("2026-01-05T09:00:00Z"), T("2026-01-05T10:00:00Z")],
|
|
106
|
+
[T("2026-01-05T09:30:00Z"), T("2026-01-05T11:00:00Z")],
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("leaves an unmatched start OPEN rather than inventing a completion", () => {
|
|
111
|
+
const log: EventLog = {
|
|
112
|
+
events: [
|
|
113
|
+
{ caseId: "c1", activity: "Pick", lifecycle: "start", timestamp: "2026-01-05T09:00:00Z" },
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
const event = normalizeLog(log).cases[0]?.events[0];
|
|
117
|
+
expect(event?.isOpen).toBe(true);
|
|
118
|
+
expect(event?.start).toBe(event?.end);
|
|
119
|
+
expect(event?.duration).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("uses an explicit startTimestamp when a complete row has no matching start", () => {
|
|
123
|
+
const log: EventLog = {
|
|
124
|
+
events: [
|
|
125
|
+
{
|
|
126
|
+
caseId: "c1",
|
|
127
|
+
activity: "Ship",
|
|
128
|
+
lifecycle: "complete",
|
|
129
|
+
startTimestamp: "2026-01-05T09:00:00Z",
|
|
130
|
+
timestamp: "2026-01-05T10:00:00Z",
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
expect(normalizeLog(log).cases[0]?.events[0]).toMatchObject({
|
|
135
|
+
start: T("2026-01-05T09:00:00Z"),
|
|
136
|
+
duration: 3_600_000,
|
|
137
|
+
isOpen: false,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("treats a row with no lifecycle as atomic", () => {
|
|
142
|
+
const log: EventLog = {
|
|
143
|
+
events: [{ caseId: "c1", activity: "Ship", timestamp: "2026-01-05T10:00:00Z" }],
|
|
144
|
+
};
|
|
145
|
+
const event = normalizeLog(log).cases[0]?.events[0];
|
|
146
|
+
expect(event?.start).toBe(event?.end);
|
|
147
|
+
expect(event?.duration).toBe(0);
|
|
148
|
+
expect(event?.isOpen).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("floors a clock-skewed pair at zero instead of reporting a negative duration", () => {
|
|
152
|
+
const log: EventLog = {
|
|
153
|
+
events: [
|
|
154
|
+
{
|
|
155
|
+
caseId: "c1",
|
|
156
|
+
activity: "Ship",
|
|
157
|
+
startTimestamp: "2026-01-05T11:00:00Z",
|
|
158
|
+
timestamp: "2026-01-05T10:00:00Z",
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
expect(normalizeLog(log).cases[0]?.events[0]?.duration).toBe(0);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("carries resources, event attributes and case attributes through", () => {
|
|
166
|
+
const log: EventLog = {
|
|
167
|
+
events: [
|
|
168
|
+
{
|
|
169
|
+
caseId: "c1",
|
|
170
|
+
activity: "Ship",
|
|
171
|
+
timestamp: "2026-01-05T10:00:00Z",
|
|
172
|
+
resource: "Warehouse Robot",
|
|
173
|
+
attributes: { carrier: "DPD", cost: 12.5 },
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
caseAttributes: { c1: { region: "North" } },
|
|
177
|
+
};
|
|
178
|
+
const [kase] = normalizeLog(log).cases;
|
|
179
|
+
expect(kase?.attributes).toEqual({ region: "North" });
|
|
180
|
+
expect(kase?.events[0]).toMatchObject({
|
|
181
|
+
resource: "Warehouse Robot",
|
|
182
|
+
attributes: { carrier: "DPD", cost: 12.5 },
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("drops rows with no case id or no activity", () => {
|
|
187
|
+
const log: EventLog = {
|
|
188
|
+
events: [
|
|
189
|
+
{ caseId: "", activity: "Ship", timestamp: 1 },
|
|
190
|
+
{ caseId: "c1", activity: "", timestamp: 2 },
|
|
191
|
+
{ caseId: "c1", activity: "Ship", timestamp: 3 },
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
expect(normalizeLog(log).totals).toEqual({ cases: 1, events: 1 });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("keeps rows with an unparseable timestamp in their input order", () => {
|
|
198
|
+
const log: EventLog = {
|
|
199
|
+
events: [
|
|
200
|
+
{ caseId: "c1", activity: "First", timestamp: "nonsense" },
|
|
201
|
+
{ caseId: "c1", activity: "Second", timestamp: "also nonsense" },
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
expect(normalizeLog(log).cases[0]?.events.map((e) => e.activity)).toEqual(["First", "Second"]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("answers an empty log with an empty result", () => {
|
|
208
|
+
expect(normalizeLog({ events: [] })).toEqual({ cases: [], totals: { cases: 0, events: 0 } });
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("asNormalizedLog", () => {
|
|
213
|
+
it("is idempotent — a normalized log passes through untouched", () => {
|
|
214
|
+
const once = normalizeLog({
|
|
215
|
+
events: [{ caseId: "c1", activity: "Ship", timestamp: 10 }],
|
|
216
|
+
});
|
|
217
|
+
expect(isNormalizedLog(once)).toBe(true);
|
|
218
|
+
expect(asNormalizedLog(once)).toBe(once);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("recognizes a raw log", () => {
|
|
222
|
+
expect(isNormalizedLog({ events: [] })).toBe(false);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event-log normalization — RM-049.
|
|
3
|
+
*
|
|
4
|
+
* Turns a flat `EventRow[]` into per-case traces of INSTANCES: one entry per real
|
|
5
|
+
* activity execution, carrying a resolved `start` and `end` in epoch milliseconds. Every
|
|
6
|
+
* other module in `/core` consumes the normalized form, so the messy parts of a raw log
|
|
7
|
+
* (three timestamp encodings, lifecycle rows split across two lines, rows arriving out of
|
|
8
|
+
* order) are handled exactly once, here.
|
|
9
|
+
*
|
|
10
|
+
* Deterministic: same rows in, same traces out. No `Date.now()`, no randomness.
|
|
11
|
+
*/
|
|
12
|
+
import type { EventLog, EventRow } from "./types";
|
|
13
|
+
|
|
14
|
+
/** One activity execution, with both ends resolved to epoch milliseconds. */
|
|
15
|
+
export interface NormalizedEvent {
|
|
16
|
+
activity: string;
|
|
17
|
+
/**
|
|
18
|
+
* Start of the execution. Equals `end` for an atomic event — an event with no
|
|
19
|
+
* `lifecycle` pair and no `startTimestamp` has no observed duration, and inventing one
|
|
20
|
+
* would put fabricated numbers into every downstream statistic.
|
|
21
|
+
*/
|
|
22
|
+
start: number;
|
|
23
|
+
/** Completion of the execution. */
|
|
24
|
+
end: number;
|
|
25
|
+
/** `end - start`, never negative (a clock-skewed pair is floored at 0). */
|
|
26
|
+
duration: number;
|
|
27
|
+
resource?: string;
|
|
28
|
+
attributes?: Record<string, string | number | boolean | null>;
|
|
29
|
+
/**
|
|
30
|
+
* True when this instance came from a `lifecycle: "start"` row that never got a
|
|
31
|
+
* matching `"complete"`. Its `end` is provisional (it equals `start`), so a performance
|
|
32
|
+
* view can choose to exclude it.
|
|
33
|
+
*/
|
|
34
|
+
isOpen: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One case (process instance): its trace, plus the case's own extent. */
|
|
38
|
+
export interface NormalizedCase {
|
|
39
|
+
caseId: string;
|
|
40
|
+
/** Instances in ascending `start` order. Never empty. */
|
|
41
|
+
events: NormalizedEvent[];
|
|
42
|
+
/** Earliest `start` in the trace. */
|
|
43
|
+
start: number;
|
|
44
|
+
/** Latest `end` in the trace. */
|
|
45
|
+
end: number;
|
|
46
|
+
/** `end - start` — the case's throughput time. */
|
|
47
|
+
duration: number;
|
|
48
|
+
/** Per-case attributes carried over from `EventLog.caseAttributes`, when present. */
|
|
49
|
+
attributes?: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A normalized log: cases in first-appearance order, plus totals. */
|
|
53
|
+
export interface NormalizedLog {
|
|
54
|
+
cases: NormalizedCase[];
|
|
55
|
+
totals: { cases: number; events: number };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A log in either shape. Every derivation entry point accepts both. */
|
|
59
|
+
export type AnyLog = EventLog | NormalizedLog;
|
|
60
|
+
|
|
61
|
+
/** Narrowing predicate — `true` when `log` has already been through {@link normalizeLog}. */
|
|
62
|
+
export function isNormalizedLog(log: AnyLog): log is NormalizedLog {
|
|
63
|
+
return Array.isArray((log as NormalizedLog).cases);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Normalize `log` unless it already is normalized. The idempotent entry point. */
|
|
67
|
+
export function asNormalizedLog(log: AnyLog): NormalizedLog {
|
|
68
|
+
return isNormalizedLog(log) ? log : normalizeLog(log);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the three accepted timestamp encodings to epoch milliseconds.
|
|
73
|
+
*
|
|
74
|
+
* Returns `NaN` for an unparseable value rather than throwing: one bad cell in a
|
|
75
|
+
* ten-thousand-row CSV must not abort the whole import. Downstream, a `NaN` sorts as
|
|
76
|
+
* equal (so the row keeps its input position) and is dropped from duration samples.
|
|
77
|
+
*/
|
|
78
|
+
export function toEpochMs(value: string | number | Date | undefined | null): number {
|
|
79
|
+
if (value === undefined || value === null) return Number.NaN;
|
|
80
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : Number.NaN;
|
|
81
|
+
if (value instanceof Date) return value.getTime();
|
|
82
|
+
const parsed = Date.parse(value);
|
|
83
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
84
|
+
// A bare numeric string ("1735725600000") is a legitimate epoch encoding that
|
|
85
|
+
// `Date.parse` reads as a year in some engines; treat it as epoch ms explicitly.
|
|
86
|
+
const asNumber = Number(value);
|
|
87
|
+
return Number.isFinite(asNumber) && value.trim() !== "" ? asNumber : Number.NaN;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Stable comparator that leaves `NaN`-timestamped rows in their input order. */
|
|
91
|
+
function byTimestamp(a: { at: number }, b: { at: number }): number {
|
|
92
|
+
if (Number.isNaN(a.at) || Number.isNaN(b.at)) return 0;
|
|
93
|
+
return a.at - b.at;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface StagedRow {
|
|
97
|
+
at: number;
|
|
98
|
+
row: EventRow;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Group rows by case, order each case in time, and pair `lifecycle` rows into instances.
|
|
103
|
+
*
|
|
104
|
+
* The pairing rule, stated precisely because five downstream items depend on it:
|
|
105
|
+
*
|
|
106
|
+
* - Rows are grouped by `caseId` in ONE pass and each case is sorted independently. The
|
|
107
|
+
* whole log is never sorted — sorting 200 000 rows to then split them is the shape that
|
|
108
|
+
* makes a browser import feel broken.
|
|
109
|
+
* - A `lifecycle: "start"` row OPENS an instance at its position in the case's timeline.
|
|
110
|
+
* - A `lifecycle: "complete"` row CLOSES the oldest still-open instance of the SAME
|
|
111
|
+
* activity, wherever that start sits in the trace. The two rows therefore do not have
|
|
112
|
+
* to be adjacent: interleaved `A-start, B-start, A-complete, B-complete` pairs
|
|
113
|
+
* correctly, and so does a log whose rows arrive in the wrong order entirely, because
|
|
114
|
+
* the per-case sort runs first.
|
|
115
|
+
* - Repeated executions of one activity in a case pair oldest-start-to-earliest-complete
|
|
116
|
+
* (FIFO), which is the only pairing that keeps intervals non-overlapping for a
|
|
117
|
+
* sequential resource.
|
|
118
|
+
* - A `"complete"` with no open start becomes an instance in its own right, using an
|
|
119
|
+
* explicit `startTimestamp` when the row carries one.
|
|
120
|
+
* - A `"start"` that is never completed stays OPEN: `end === start` and `isOpen` is true.
|
|
121
|
+
* - A row with no `lifecycle` is atomic: `start` comes from `startTimestamp` when given,
|
|
122
|
+
* otherwise from `timestamp` itself.
|
|
123
|
+
*
|
|
124
|
+
* Rows with an empty `caseId` or `activity` are dropped — they cannot take part in a
|
|
125
|
+
* directly-follows relation, and keeping them would put an unnamed node in every graph.
|
|
126
|
+
*/
|
|
127
|
+
export function normalizeLog(log: EventLog): NormalizedLog {
|
|
128
|
+
const staged = new Map<string, StagedRow[]>();
|
|
129
|
+
const order: string[] = [];
|
|
130
|
+
|
|
131
|
+
for (const row of log.events) {
|
|
132
|
+
if (!row || typeof row.caseId !== "string" || row.caseId === "") continue;
|
|
133
|
+
if (typeof row.activity !== "string" || row.activity === "") continue;
|
|
134
|
+
let bucket = staged.get(row.caseId);
|
|
135
|
+
if (bucket === undefined) {
|
|
136
|
+
bucket = [];
|
|
137
|
+
staged.set(row.caseId, bucket);
|
|
138
|
+
order.push(row.caseId);
|
|
139
|
+
}
|
|
140
|
+
bucket.push({ at: toEpochMs(row.timestamp), row });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const cases: NormalizedCase[] = [];
|
|
144
|
+
let events = 0;
|
|
145
|
+
|
|
146
|
+
for (const caseId of order) {
|
|
147
|
+
const bucket = staged.get(caseId) as StagedRow[];
|
|
148
|
+
// `Array.prototype.sort` is stable (ES2019), so equal timestamps keep input order.
|
|
149
|
+
bucket.sort(byTimestamp);
|
|
150
|
+
|
|
151
|
+
const instances: NormalizedEvent[] = [];
|
|
152
|
+
/** activity → FIFO queue of indices into `instances` awaiting a `"complete"`. */
|
|
153
|
+
const open = new Map<string, number[]>();
|
|
154
|
+
|
|
155
|
+
for (const { at, row } of bucket) {
|
|
156
|
+
if (row.lifecycle === "start") {
|
|
157
|
+
const index = instances.length;
|
|
158
|
+
instances.push(makeInstance(row, at, at, true));
|
|
159
|
+
const queue = open.get(row.activity);
|
|
160
|
+
if (queue === undefined) open.set(row.activity, [index]);
|
|
161
|
+
else queue.push(index);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (row.lifecycle === "complete") {
|
|
166
|
+
const queue = open.get(row.activity);
|
|
167
|
+
const index = queue?.shift();
|
|
168
|
+
if (index !== undefined) {
|
|
169
|
+
const instance = instances[index] as NormalizedEvent;
|
|
170
|
+
instance.end = at;
|
|
171
|
+
instance.duration = durationOf(instance.start, at);
|
|
172
|
+
instance.isOpen = false;
|
|
173
|
+
if (instance.resource === undefined && row.resource !== undefined) {
|
|
174
|
+
instance.resource = row.resource;
|
|
175
|
+
}
|
|
176
|
+
if (instance.attributes === undefined && row.attributes !== undefined) {
|
|
177
|
+
instance.attributes = row.attributes;
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const end = at;
|
|
184
|
+
const explicitStart = toEpochMs(row.startTimestamp);
|
|
185
|
+
const start = Number.isNaN(explicitStart) ? end : explicitStart;
|
|
186
|
+
instances.push(makeInstance(row, start, end, false));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (instances.length === 0) continue;
|
|
190
|
+
|
|
191
|
+
// Pairing can move an instance's start earlier than the row that created it, so the
|
|
192
|
+
// trace is re-ordered by resolved start. It is already almost sorted, which is the
|
|
193
|
+
// best case for the engine's TimSort — this is not a second full sort in practice.
|
|
194
|
+
instances.sort(byStart);
|
|
195
|
+
|
|
196
|
+
let caseStart = Number.POSITIVE_INFINITY;
|
|
197
|
+
let caseEnd = Number.NEGATIVE_INFINITY;
|
|
198
|
+
for (const instance of instances) {
|
|
199
|
+
if (Number.isFinite(instance.start) && instance.start < caseStart) caseStart = instance.start;
|
|
200
|
+
if (Number.isFinite(instance.end) && instance.end > caseEnd) caseEnd = instance.end;
|
|
201
|
+
}
|
|
202
|
+
const hasExtent =
|
|
203
|
+
caseStart !== Number.POSITIVE_INFINITY && caseEnd !== Number.NEGATIVE_INFINITY;
|
|
204
|
+
|
|
205
|
+
const normalizedCase: NormalizedCase = {
|
|
206
|
+
caseId,
|
|
207
|
+
events: instances,
|
|
208
|
+
start: hasExtent ? caseStart : Number.NaN,
|
|
209
|
+
end: hasExtent ? caseEnd : Number.NaN,
|
|
210
|
+
duration: hasExtent ? durationOf(caseStart, caseEnd) : Number.NaN,
|
|
211
|
+
};
|
|
212
|
+
const caseAttributes = log.caseAttributes?.[caseId];
|
|
213
|
+
if (caseAttributes !== undefined) normalizedCase.attributes = caseAttributes;
|
|
214
|
+
|
|
215
|
+
cases.push(normalizedCase);
|
|
216
|
+
events += instances.length;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { cases, totals: { cases: cases.length, events } };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function byStart(a: NormalizedEvent, b: NormalizedEvent): number {
|
|
223
|
+
if (Number.isNaN(a.start) || Number.isNaN(b.start)) return 0;
|
|
224
|
+
return a.start - b.start;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function durationOf(start: number, end: number): number {
|
|
228
|
+
const delta = end - start;
|
|
229
|
+
if (!Number.isFinite(delta)) return Number.NaN;
|
|
230
|
+
return delta < 0 ? 0 : delta;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function makeInstance(row: EventRow, start: number, end: number, isOpen: boolean): NormalizedEvent {
|
|
234
|
+
const instance: NormalizedEvent = {
|
|
235
|
+
activity: row.activity,
|
|
236
|
+
start,
|
|
237
|
+
end,
|
|
238
|
+
duration: durationOf(start, end),
|
|
239
|
+
isOpen,
|
|
240
|
+
};
|
|
241
|
+
if (row.resource !== undefined) instance.resource = row.resource;
|
|
242
|
+
if (row.attributes !== undefined) instance.attributes = row.attributes;
|
|
243
|
+
return instance;
|
|
244
|
+
}
|