@elabs-ai/components-process 4.2.0 → 5.0.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/README.md +8 -1
- package/dist/core/index.d.ts +801 -3
- package/dist/core/index.js +1334 -0
- package/dist/core/index.js.map +1 -1
- package/dist/index.d.ts +1889 -34
- package/dist/index.js +5512 -196
- package/dist/index.js.map +1 -1
- package/dist/test/index.d.ts +223 -5
- package/dist/test/index.js +346 -191
- package/dist/test/index.js.map +1 -1
- package/package.json +14 -13
- package/src/__contract__/case-table.contract.test.tsx +49 -0
- package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
- package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
- package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
- package/src/__contract__/violation-list.contract.test.tsx +49 -0
- package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
- package/src/abstraction-controls/abstraction-controls.tsx +198 -5
- package/src/case-table/case-table.stories.tsx +89 -0
- package/src/case-table/case-table.test.tsx +148 -0
- package/src/case-table/case-table.tsx +144 -0
- package/src/case-table/columns.ts +116 -0
- package/src/case-table/index.ts +11 -0
- package/src/case-timeline/case-timeline-model.test.ts +72 -0
- package/src/case-timeline/case-timeline-model.ts +112 -0
- package/src/case-timeline/case-timeline.stories.tsx +94 -0
- package/src/case-timeline/case-timeline.test.tsx +51 -0
- package/src/case-timeline/case-timeline.tsx +109 -0
- package/src/case-timeline/index.ts +9 -0
- package/src/conformance-overlay/conformance-fixture.ts +59 -0
- package/src/conformance-overlay/conformance-legend.tsx +109 -0
- package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
- package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
- package/src/conformance-overlay/conformance-overlay.tsx +107 -0
- package/src/conformance-overlay/conformance-state.test.ts +79 -0
- package/src/conformance-overlay/conformance-state.ts +220 -0
- package/src/conformance-overlay/index.ts +4 -0
- package/src/core/activity-color-scale.test.ts +107 -0
- package/src/core/activity-color-scale.ts +133 -0
- package/src/core/adapters/ocel.test.ts +112 -0
- package/src/core/adapters/ocel.ts +359 -0
- package/src/core/adapters/xes.test.ts +293 -0
- package/src/core/adapters/xes.ts +384 -0
- package/src/core/cases-from-log.test.ts +72 -0
- package/src/core/cases-from-log.ts +85 -0
- package/src/core/conformance.test.ts +80 -0
- package/src/core/conformance.ts +91 -0
- package/src/core/diff-graphs.test.ts +151 -0
- package/src/core/diff-graphs.ts +118 -0
- package/src/core/discover-object-centric-graph.test.ts +94 -0
- package/src/core/discover-object-centric-graph.ts +296 -0
- package/src/core/fixtures/ocel-sample.ts +82 -0
- package/src/core/fixtures/sample.xes +68 -0
- package/src/core/index.ts +113 -0
- package/src/core/reference-model.test.ts +43 -0
- package/src/core/reference-model.ts +116 -0
- package/src/core/replay-timeline.test.ts +161 -0
- package/src/core/replay-timeline.ts +260 -0
- package/src/core/segments.test.ts +185 -0
- package/src/core/segments.ts +153 -0
- package/src/core/token-replay.test.ts +218 -0
- package/src/core/token-replay.ts +456 -0
- package/src/core/types.ts +2 -2
- package/src/dotted-chart/compute-dots.test.ts +176 -0
- package/src/dotted-chart/compute-dots.ts +241 -0
- package/src/dotted-chart/dotted-chart-labels.ts +93 -0
- package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
- package/src/dotted-chart/dotted-chart.test.tsx +135 -0
- package/src/dotted-chart/dotted-chart.tsx +841 -0
- package/src/dotted-chart/index.ts +23 -0
- package/src/dotted-chart/use-element-size.ts +33 -0
- package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
- package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
- package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
- package/src/happy-path-editor/happy-path-editor.tsx +239 -0
- package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
- package/src/happy-path-editor/index.ts +4 -0
- package/src/index.ts +51 -1
- package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
- package/src/performance-spectrum/aggregate-segments.ts +174 -0
- package/src/performance-spectrum/index.ts +25 -0
- package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
- package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
- package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
- package/src/performance-spectrum/performance-spectrum.tsx +870 -0
- package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
- package/src/process-compare/compare-kpi-strip.tsx +94 -0
- package/src/process-compare/compare-model.ts +83 -0
- package/src/process-compare/compare-side.tsx +42 -0
- package/src/process-compare/diff-to-graph.ts +104 -0
- package/src/process-compare/index.ts +23 -0
- package/src/process-compare/process-compare.stories.tsx +184 -0
- package/src/process-compare/process-compare.test.tsx +224 -0
- package/src/process-compare/process-compare.tsx +251 -0
- package/src/process-explorer.stories.tsx +1 -1
- package/src/process-filter-bar/index.ts +2 -0
- package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
- package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
- package/src/process-filter-bar/process-filter-bar.tsx +167 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
- package/src/process-map/activity-accent.ts +25 -0
- package/src/process-map/index.ts +1 -0
- package/src/process-map/map-model.test.ts +16 -0
- package/src/process-map/map-model.ts +323 -1
- package/src/process-map/object-centric-map.test.tsx +132 -0
- package/src/process-map/process-activity-node.tsx +152 -14
- package/src/process-map/process-map-object-centric.stories.tsx +219 -0
- package/src/process-map/process-map.stories.tsx +64 -0
- package/src/process-map/process-map.tsx +240 -16
- package/src/process-map/process-transition-edge.test.tsx +47 -0
- package/src/process-map/process-transition-edge.tsx +134 -7
- package/src/process-map/use-process-layout.ts +30 -9
- package/src/process-replay/congestion-heat.tsx +107 -0
- package/src/process-replay/index.ts +14 -0
- package/src/process-replay/process-replay.stories.tsx +168 -0
- package/src/process-replay/process-replay.test.tsx +170 -0
- package/src/process-replay/process-replay.tsx +285 -0
- package/src/process-replay/replay-controls.tsx +147 -0
- package/src/process-replay/replay-format.ts +83 -0
- package/src/process-replay/replay-tokens-context.ts +30 -0
- package/src/process-replay/use-controllable-value.ts +30 -0
- package/src/templates-process-explorer.stories.tsx +1304 -0
- package/src/test/contract.test.ts +66 -0
- package/src/test/contract.ts +107 -6
- package/src/test/doubles.test.tsx +87 -1
- package/src/test/doubles.tsx +174 -3
- package/src/test/index.ts +25 -1
- package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
- package/src/use-process-explorer/use-process-explorer.ts +34 -2
- package/src/variant-explorer/coverage-bar.tsx +36 -0
- package/src/variant-explorer/index.ts +16 -0
- package/src/variant-explorer/sequence-chips.tsx +103 -0
- package/src/variant-explorer/variant-explorer-model.ts +42 -0
- package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
- package/src/variant-explorer/variant-explorer.test.tsx +302 -0
- package/src/variant-explorer/variant-explorer.tsx +567 -0
- package/src/variant-explorer/variant-row.tsx +137 -0
- package/src/violation-list/index.ts +2 -0
- package/src/violation-list/violation-list.stories.tsx +73 -0
- package/src/violation-list/violation-list.test.tsx +84 -0
- 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
|
+
});
|