@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,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-based replay — RM-061.
|
|
3
|
+
*
|
|
4
|
+
* Replays each case of an event log against a {@link ReplayModel} (a lifted happy path)
|
|
5
|
+
* and scores it with the classic produced / consumed / missing / remaining token counts:
|
|
6
|
+
*
|
|
7
|
+
* fitness = ½ · (1 − missing / consumed) + ½ · (1 − remaining / produced)
|
|
8
|
+
*
|
|
9
|
+
* The replay procedure — seed the initial marking, fire an enabled transition per event,
|
|
10
|
+
* enable a disabled one through silent transitions first, force-insert missing tokens
|
|
11
|
+
* only when that fails, then consume the final marking and count what is left over —
|
|
12
|
+
* follows the token-based replay algorithm published in pm4js (BSD-3-Clause, credited in
|
|
13
|
+
* `scripts/attributions.sources.json`). It is re-typed in TypeScript for the restricted
|
|
14
|
+
* nets `liftHappyPath` builds; no pm4js code is copied, and nothing here derives from the
|
|
15
|
+
* AGPL Python reference implementation.
|
|
16
|
+
*
|
|
17
|
+
* Deviation typing is this module's own layer on top of the counts: every forced token
|
|
18
|
+
* (and every unreachable final token) is explained by at least one
|
|
19
|
+
* {@link Deviation}, so a violation list can say WHY a case lost fitness.
|
|
20
|
+
*
|
|
21
|
+
* Scope boundary (analysis §9 risk 2): no alignment search. Replay is greedy and
|
|
22
|
+
* single-pass per trace.
|
|
23
|
+
*
|
|
24
|
+
* Framework-free, deterministic: no React, no `@elabs-ai/components-*` import.
|
|
25
|
+
*/
|
|
26
|
+
import type { ConformanceResult } from "./conformance";
|
|
27
|
+
import { EDGE_KEY_SEPARATOR } from "./discover-graph";
|
|
28
|
+
import { asNormalizedLog, normalizeLog, type AnyLog, type NormalizedEvent } from "./event-log";
|
|
29
|
+
import type { ReplayModel, ReplayTransition } from "./reference-model";
|
|
30
|
+
import type { EventRow } from "./types";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Why a case lost fitness.
|
|
34
|
+
*
|
|
35
|
+
* - `undesired` — the activity is not in the model at all.
|
|
36
|
+
* - `skipped` — a required modelled activity was jumped over and never observed in the case.
|
|
37
|
+
* - `wrongOrder` — the activity is modelled but its preceding place was unmarked, and the
|
|
38
|
+
* gap is not explained by an unobserved step (it ran too early, too late, or again).
|
|
39
|
+
* - `wrongStart` — the first event of the case is not one the initial marking enables.
|
|
40
|
+
* - `incomplete` — the case ended before reaching the model's final marking.
|
|
41
|
+
*/
|
|
42
|
+
export type DeviationType = "undesired" | "skipped" | "wrongOrder" | "wrongStart" | "incomplete";
|
|
43
|
+
|
|
44
|
+
/** Every {@link DeviationType}, in a stable display order. */
|
|
45
|
+
export const DEVIATION_TYPES: readonly DeviationType[] = [
|
|
46
|
+
"undesired",
|
|
47
|
+
"skipped",
|
|
48
|
+
"wrongOrder",
|
|
49
|
+
"wrongStart",
|
|
50
|
+
"incomplete",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** One deviation found while replaying a case. */
|
|
54
|
+
export interface Deviation {
|
|
55
|
+
type: DeviationType;
|
|
56
|
+
/**
|
|
57
|
+
* The activity the deviation is about: the observed event for `undesired`/`wrongOrder`/
|
|
58
|
+
* `wrongStart`, the missing step for `skipped`, the last observed event for `incomplete`.
|
|
59
|
+
*/
|
|
60
|
+
activity?: string;
|
|
61
|
+
/** The activity the model expected next at that point, when one is determinable. */
|
|
62
|
+
expected?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Zero-based index into the case's ordered trace of the event where the deviation was
|
|
65
|
+
* detected. `incomplete` uses the trace length (the position after the last event).
|
|
66
|
+
*/
|
|
67
|
+
at: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Replay result for one case. */
|
|
71
|
+
export interface TraceReplayResult {
|
|
72
|
+
caseId: string;
|
|
73
|
+
produced: number;
|
|
74
|
+
consumed: number;
|
|
75
|
+
missing: number;
|
|
76
|
+
remaining: number;
|
|
77
|
+
/** `½(1 − missing/consumed) + ½(1 − remaining/produced)`, in `[0, 1]`. */
|
|
78
|
+
fitness: number;
|
|
79
|
+
deviations: Deviation[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Compiled model ────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
interface CompiledModel {
|
|
85
|
+
model: ReplayModel;
|
|
86
|
+
/** Visible (`step`/`repeat`) transitions by activity, `step`s first. */
|
|
87
|
+
byActivity: Map<string, ReplayTransition[]>;
|
|
88
|
+
/** place → outgoing single-input transitions, silent ones first. */
|
|
89
|
+
outgoing: Map<string, ReplayTransition[]>;
|
|
90
|
+
/** place → outgoing silent single-input transitions. */
|
|
91
|
+
silentOutgoing: Map<string, ReplayTransition[]>;
|
|
92
|
+
/** `step` transitions in model order — the candidates for `expected`. */
|
|
93
|
+
steps: ReplayTransition[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const compiled = new WeakMap<ReplayModel, CompiledModel>();
|
|
97
|
+
|
|
98
|
+
function pushTo<K, V>(map: Map<K, V[]>, key: K, value: V): void {
|
|
99
|
+
const list = map.get(key);
|
|
100
|
+
if (list === undefined) map.set(key, [value]);
|
|
101
|
+
else list.push(value);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function compile(model: ReplayModel): CompiledModel {
|
|
105
|
+
const cached = compiled.get(model);
|
|
106
|
+
if (cached !== undefined) return cached;
|
|
107
|
+
|
|
108
|
+
const byActivity = new Map<string, ReplayTransition[]>();
|
|
109
|
+
const outgoing = new Map<string, ReplayTransition[]>();
|
|
110
|
+
const silentOutgoing = new Map<string, ReplayTransition[]>();
|
|
111
|
+
const steps: ReplayTransition[] = [];
|
|
112
|
+
|
|
113
|
+
const silent = model.transitions.filter((t) => t.kind === "skip");
|
|
114
|
+
const visible = model.transitions.filter((t) => t.kind !== "skip");
|
|
115
|
+
|
|
116
|
+
for (const t of [...visible].sort((a, b) => rank(a) - rank(b))) {
|
|
117
|
+
pushTo(byActivity, t.activity, t);
|
|
118
|
+
}
|
|
119
|
+
for (const t of visible) if (t.kind === "step") steps.push(t);
|
|
120
|
+
// Silent first, so a breadth-first search reaches a place over a skip arc before it
|
|
121
|
+
// reaches it over the parallel visible step — an optional step is never reported
|
|
122
|
+
// `skipped`.
|
|
123
|
+
for (const t of [...silent, ...visible]) {
|
|
124
|
+
if (t.consumes.length !== 1 || t.kind === "repeat") continue;
|
|
125
|
+
const from = t.consumes[0] as string;
|
|
126
|
+
pushTo(outgoing, from, t);
|
|
127
|
+
if (t.kind === "skip") pushTo(silentOutgoing, from, t);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const result: CompiledModel = { model, byActivity, outgoing, silentOutgoing, steps };
|
|
131
|
+
compiled.set(model, result);
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function rank(t: ReplayTransition): number {
|
|
136
|
+
return t.kind === "step" ? 0 : 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Marking helpers ───────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
type Marking = Map<string, number>;
|
|
142
|
+
|
|
143
|
+
function tokens(marking: Marking, place: string): number {
|
|
144
|
+
return marking.get(place) ?? 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isEnabled(marking: Marking, t: ReplayTransition): boolean {
|
|
148
|
+
const need = new Map<string, number>();
|
|
149
|
+
for (const place of t.consumes) need.set(place, (need.get(place) ?? 0) + 1);
|
|
150
|
+
for (const [place, count] of need) if (tokens(marking, place) < count) return false;
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function fire(marking: Marking, t: ReplayTransition): void {
|
|
155
|
+
for (const place of t.consumes) marking.set(place, tokens(marking, place) - 1);
|
|
156
|
+
for (const place of t.produces) marking.set(place, tokens(marking, place) + 1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Shortest chain of transitions from any marked place to `target`, walking `edges`.
|
|
161
|
+
* Returns `undefined` when no marked place reaches it. A place that is already marked
|
|
162
|
+
* yields an empty chain.
|
|
163
|
+
*/
|
|
164
|
+
function shortestChain(
|
|
165
|
+
marking: Marking,
|
|
166
|
+
target: string,
|
|
167
|
+
edges: Map<string, ReplayTransition[]>,
|
|
168
|
+
): ReplayTransition[] | undefined {
|
|
169
|
+
if (tokens(marking, target) > 0) return [];
|
|
170
|
+
const parent = new Map<string, ReplayTransition | null>();
|
|
171
|
+
const queue: string[] = [];
|
|
172
|
+
for (const [place, count] of marking) {
|
|
173
|
+
if (count > 0 && !parent.has(place)) {
|
|
174
|
+
parent.set(place, null);
|
|
175
|
+
queue.push(place);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (let head = 0; head < queue.length; head += 1) {
|
|
179
|
+
const place = queue[head] as string;
|
|
180
|
+
for (const t of edges.get(place) ?? []) {
|
|
181
|
+
for (const next of t.produces) {
|
|
182
|
+
if (parent.has(next)) continue;
|
|
183
|
+
parent.set(next, t);
|
|
184
|
+
if (next === target) return unwind(parent, target);
|
|
185
|
+
queue.push(next);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function unwind(parent: Map<string, ReplayTransition | null>, target: string): ReplayTransition[] {
|
|
193
|
+
const chain: ReplayTransition[] = [];
|
|
194
|
+
let at = parent.get(target);
|
|
195
|
+
while (at !== null && at !== undefined) {
|
|
196
|
+
chain.unshift(at);
|
|
197
|
+
at = parent.get(at.consumes[0] as string);
|
|
198
|
+
}
|
|
199
|
+
return chain;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface Counters {
|
|
203
|
+
produced: number;
|
|
204
|
+
consumed: number;
|
|
205
|
+
missing: number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Try to put a token into every place in `places` by firing silent transitions only.
|
|
210
|
+
* Commits the firings (and counts them) only when every place ends up marked.
|
|
211
|
+
*/
|
|
212
|
+
function enableSilently(
|
|
213
|
+
cm: CompiledModel,
|
|
214
|
+
marking: Marking,
|
|
215
|
+
places: string[],
|
|
216
|
+
counters: Counters,
|
|
217
|
+
): boolean {
|
|
218
|
+
const trial = new Map(marking);
|
|
219
|
+
let produced = 0;
|
|
220
|
+
let consumed = 0;
|
|
221
|
+
for (const place of places) {
|
|
222
|
+
if (tokens(trial, place) > 0) continue;
|
|
223
|
+
const chain = shortestChain(trial, place, cm.silentOutgoing);
|
|
224
|
+
if (chain === undefined) return false;
|
|
225
|
+
for (const t of chain) {
|
|
226
|
+
if (!isEnabled(trial, t)) return false;
|
|
227
|
+
fire(trial, t);
|
|
228
|
+
consumed += t.consumes.length;
|
|
229
|
+
produced += t.produces.length;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const [place, count] of trial) marking.set(place, count);
|
|
233
|
+
counters.produced += produced;
|
|
234
|
+
counters.consumed += consumed;
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The first step the model would accept next from `marking`, looking through skip arcs. */
|
|
239
|
+
function expectedNext(cm: CompiledModel, marking: Marking): string | undefined {
|
|
240
|
+
const reachable = new Set<string>();
|
|
241
|
+
const queue: string[] = [];
|
|
242
|
+
for (const [place, count] of marking) {
|
|
243
|
+
if (count > 0) {
|
|
244
|
+
reachable.add(place);
|
|
245
|
+
queue.push(place);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for (let head = 0; head < queue.length; head += 1) {
|
|
249
|
+
for (const t of cm.silentOutgoing.get(queue[head] as string) ?? []) {
|
|
250
|
+
for (const next of t.produces) {
|
|
251
|
+
if (reachable.has(next)) continue;
|
|
252
|
+
reachable.add(next);
|
|
253
|
+
queue.push(next);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (const t of cm.steps) {
|
|
258
|
+
if (t.consumes.every((place) => reachable.has(place))) return t.activity;
|
|
259
|
+
}
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function fitnessOf(c: Counters & { remaining: number }): number {
|
|
264
|
+
const missingTerm = c.consumed > 0 ? 1 - c.missing / c.consumed : 1;
|
|
265
|
+
const remainingTerm = c.produced > 0 ? 1 - c.remaining / c.produced : 1;
|
|
266
|
+
return Math.min(1, Math.max(0, 0.5 * missingTerm + 0.5 * remainingTerm));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── Replay ────────────────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Replay an already-ordered activity sequence. The shared engine behind
|
|
273
|
+
* {@link replayTrace}, {@link tokenReplay} and `conformanceRateSeries`.
|
|
274
|
+
*/
|
|
275
|
+
export function replayActivities(
|
|
276
|
+
caseId: string,
|
|
277
|
+
trace: readonly string[],
|
|
278
|
+
model: ReplayModel,
|
|
279
|
+
): TraceReplayResult {
|
|
280
|
+
const cm = compile(model);
|
|
281
|
+
const marking: Marking = new Map();
|
|
282
|
+
const counters: Counters = { produced: 0, consumed: 0, missing: 0 };
|
|
283
|
+
const deviations: Deviation[] = [];
|
|
284
|
+
const observed = new Set(trace);
|
|
285
|
+
|
|
286
|
+
for (const place of model.initialMarking) {
|
|
287
|
+
marking.set(place, tokens(marking, place) + 1);
|
|
288
|
+
counters.produced += 1;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
trace.forEach((activity, at) => {
|
|
292
|
+
const candidates = cm.byActivity.get(activity);
|
|
293
|
+
|
|
294
|
+
if (candidates === undefined || candidates.length === 0) {
|
|
295
|
+
// Not in the model: a phantom firing that consumes one forced (missing) token.
|
|
296
|
+
deviations.push({ type: "undesired", activity, expected: expectedNext(cm, marking), at });
|
|
297
|
+
counters.missing += 1;
|
|
298
|
+
counters.consumed += 1;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const enabled =
|
|
303
|
+
candidates.find((t) => isEnabled(marking, t)) ??
|
|
304
|
+
candidates.find((t) => enableSilently(cm, marking, t.consumes, counters));
|
|
305
|
+
if (enabled !== undefined) {
|
|
306
|
+
fire(marking, enabled);
|
|
307
|
+
counters.consumed += enabled.consumes.length;
|
|
308
|
+
counters.produced += enabled.produces.length;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Force: pick the candidate the current marking is closest to, else the first `step`.
|
|
313
|
+
const expected = expectedNext(cm, marking);
|
|
314
|
+
let chosen = candidates[0] as ReplayTransition;
|
|
315
|
+
let chain: ReplayTransition[] | undefined;
|
|
316
|
+
for (const t of candidates) {
|
|
317
|
+
const gap = t.consumes.find((place) => tokens(marking, place) === 0);
|
|
318
|
+
if (gap === undefined) continue;
|
|
319
|
+
const found = shortestChain(marking, gap, cm.outgoing);
|
|
320
|
+
if (found !== undefined && (chain === undefined || found.length < chain.length)) {
|
|
321
|
+
chain = found;
|
|
322
|
+
chosen = t;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (at === 0) {
|
|
327
|
+
deviations.push({ type: "wrongStart", activity, expected, at });
|
|
328
|
+
} else {
|
|
329
|
+
const unobserved = (chain ?? []).filter(
|
|
330
|
+
(t) => t.kind === "step" && !observed.has(t.activity),
|
|
331
|
+
);
|
|
332
|
+
if (unobserved.length > 0) {
|
|
333
|
+
const seen = new Set<string>();
|
|
334
|
+
for (const t of unobserved) {
|
|
335
|
+
if (seen.has(t.activity)) continue;
|
|
336
|
+
seen.add(t.activity);
|
|
337
|
+
deviations.push({ type: "skipped", activity: t.activity, expected: t.activity, at });
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
deviations.push({ type: "wrongOrder", activity, expected, at });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
for (const place of chosen.consumes) {
|
|
345
|
+
if (tokens(marking, place) === 0) {
|
|
346
|
+
marking.set(place, 1);
|
|
347
|
+
counters.missing += 1;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
fire(marking, chosen);
|
|
351
|
+
counters.consumed += chosen.consumes.length;
|
|
352
|
+
counters.produced += chosen.produces.length;
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Consume the final marking, reaching it through skip arcs where the tail is optional.
|
|
356
|
+
enableSilently(cm, marking, model.finalMarking, counters);
|
|
357
|
+
const expected = expectedNext(cm, marking);
|
|
358
|
+
let reachedEnd = true;
|
|
359
|
+
for (const place of model.finalMarking) {
|
|
360
|
+
if (tokens(marking, place) > 0) marking.set(place, tokens(marking, place) - 1);
|
|
361
|
+
else {
|
|
362
|
+
counters.missing += 1;
|
|
363
|
+
reachedEnd = false;
|
|
364
|
+
}
|
|
365
|
+
counters.consumed += 1;
|
|
366
|
+
}
|
|
367
|
+
if (!reachedEnd) {
|
|
368
|
+
deviations.push({
|
|
369
|
+
type: "incomplete",
|
|
370
|
+
activity: trace.length > 0 ? trace[trace.length - 1] : undefined,
|
|
371
|
+
expected,
|
|
372
|
+
at: trace.length,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let remaining = 0;
|
|
377
|
+
for (const count of marking.values()) remaining += count;
|
|
378
|
+
|
|
379
|
+
const result = { caseId, ...counters, remaining };
|
|
380
|
+
return { ...result, fitness: fitnessOf(result), deviations };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function activitiesOf(events: readonly NormalizedEvent[]): string[] {
|
|
384
|
+
return events.map((event) => event.activity);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Replay ONE case's raw rows against `model`.
|
|
389
|
+
*
|
|
390
|
+
* Rows are normalized first (ordered in time, lifecycle pairs merged into one instance),
|
|
391
|
+
* exactly as every other `/core` derivation sees them. The rows are expected to share one
|
|
392
|
+
* `caseId`; if they do not, every instance is replayed as one trace in start order under
|
|
393
|
+
* the first row's `caseId`.
|
|
394
|
+
*/
|
|
395
|
+
export function replayTrace(events: EventRow[], model: ReplayModel): TraceReplayResult {
|
|
396
|
+
const normalized = normalizeLog({ events });
|
|
397
|
+
const instances = normalized.cases.flatMap((kase) => kase.events);
|
|
398
|
+
if (normalized.cases.length > 1) instances.sort((a, b) => a.start - b.start);
|
|
399
|
+
const caseId = normalized.cases[0]?.caseId ?? events[0]?.caseId ?? "";
|
|
400
|
+
return replayActivities(caseId, activitiesOf(instances), model);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** A {@link DeviationType}-keyed tally with every type present at zero. */
|
|
404
|
+
export function emptyDeviationCounts(): Record<DeviationType, number> {
|
|
405
|
+
return { undesired: 0, skipped: 0, wrongOrder: 0, wrongStart: 0, incomplete: 0 };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Replay every case of `log` against `model` and fold the results.
|
|
410
|
+
*
|
|
411
|
+
* - `deviationCounts[type]` — total deviations of that type across all cases.
|
|
412
|
+
* - `perActivity[activity].deviations` — every deviation carries an activity, so these sum
|
|
413
|
+
* to the total deviation count.
|
|
414
|
+
* - `perEdge[source + EDGE_KEY_SEPARATOR + target].deviations` — a deviation detected at an
|
|
415
|
+
* event with a predecessor is charged to the OBSERVED directly-follows edge into that
|
|
416
|
+
* event (the key `discoverGraph` uses for the same edge). A deviation at the first event
|
|
417
|
+
* or at the end of the case has no edge, so these sum to at most the total.
|
|
418
|
+
* - `overallFitness` — mean trace fitness; `0` for an empty log.
|
|
419
|
+
*/
|
|
420
|
+
export function tokenReplay(log: AnyLog, model: ReplayModel): ConformanceResult {
|
|
421
|
+
const normalized = asNormalizedLog(log);
|
|
422
|
+
const traces: TraceReplayResult[] = [];
|
|
423
|
+
const deviationCounts = emptyDeviationCounts();
|
|
424
|
+
// Prototype-free, so an activity named `constructor` or `__proto__` is just a key.
|
|
425
|
+
const perActivity = Object.create(null) as Record<string, { deviations: number }>;
|
|
426
|
+
const perEdge = Object.create(null) as Record<string, { deviations: number }>;
|
|
427
|
+
let fitnessSum = 0;
|
|
428
|
+
|
|
429
|
+
for (const kase of normalized.cases) {
|
|
430
|
+
const trace = activitiesOf(kase.events);
|
|
431
|
+
const result = replayActivities(kase.caseId, trace, model);
|
|
432
|
+
traces.push(result);
|
|
433
|
+
fitnessSum += result.fitness;
|
|
434
|
+
|
|
435
|
+
for (const deviation of result.deviations) {
|
|
436
|
+
deviationCounts[deviation.type] += 1;
|
|
437
|
+
if (deviation.activity !== undefined) {
|
|
438
|
+
const entry = (perActivity[deviation.activity] ??= { deviations: 0 });
|
|
439
|
+
entry.deviations += 1;
|
|
440
|
+
}
|
|
441
|
+
if (deviation.at > 0 && deviation.at < trace.length) {
|
|
442
|
+
const key = `${trace[deviation.at - 1]}${EDGE_KEY_SEPARATOR}${trace[deviation.at]}`;
|
|
443
|
+
const entry = (perEdge[key] ??= { deviations: 0 });
|
|
444
|
+
entry.deviations += 1;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
overallFitness: traces.length > 0 ? fitnessSum / traces.length : 0,
|
|
451
|
+
traces,
|
|
452
|
+
deviationCounts,
|
|
453
|
+
perActivity,
|
|
454
|
+
perEdge,
|
|
455
|
+
};
|
|
456
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* additive: a required field added here is a breaking change for five items at once.
|
|
8
8
|
*
|
|
9
9
|
* NOTHING in this module — or anywhere under `src/core/` — may import React, React Flow,
|
|
10
|
-
* visx, d3 or an `@elabs-ai/components-*` package. See `.claude/rules/
|
|
11
|
-
* and `pnpm process
|
|
10
|
+
* visx, d3 or an `@elabs-ai/components-*` package. See `.claude/rules/data.md` ("Process
|
|
11
|
+
* mining" section) and `pnpm check --rule process-reuse`.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { ACTIVITY_COLOR_SLOTS, ACTIVITY_OTHER_TOKEN } from "../core/activity-color-scale";
|
|
3
|
+
import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
|
|
4
|
+
import type { EventLog } from "../core/types";
|
|
5
|
+
import { casesInBrush, computeDots, rankCategoryColors } from "./compute-dots";
|
|
6
|
+
|
|
7
|
+
const HOUR = 3_600_000;
|
|
8
|
+
const DAY = 24 * HOUR;
|
|
9
|
+
/** 2026-01-05 00:00 UTC — a Monday. */
|
|
10
|
+
const MONDAY = Date.UTC(2026, 0, 5);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* c-long starts first and runs longest; c-late starts last but ends before c-long;
|
|
14
|
+
* c-short is the shortest and starts earliest in its day.
|
|
15
|
+
*/
|
|
16
|
+
const LOG: EventLog = {
|
|
17
|
+
events: [
|
|
18
|
+
{ caseId: "c-long", activity: "A", timestamp: MONDAY + 9 * HOUR },
|
|
19
|
+
{ caseId: "c-long", activity: "B", timestamp: MONDAY + 2 * DAY + 10 * HOUR },
|
|
20
|
+
{ caseId: "c-short", activity: "A", timestamp: MONDAY + DAY + 6 * HOUR },
|
|
21
|
+
{ caseId: "c-short", activity: "C", timestamp: MONDAY + DAY + 7 * HOUR },
|
|
22
|
+
{ caseId: "c-late", activity: "A", timestamp: MONDAY + DAY + 20 * HOUR },
|
|
23
|
+
{ caseId: "c-late", activity: "B", timestamp: MONDAY + 2 * DAY + 2 * HOUR },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const ids = (rows: { caseId: string }[]) => rows.map((row) => row.caseId);
|
|
28
|
+
|
|
29
|
+
describe("computeDots — rows", () => {
|
|
30
|
+
it("groups one row per case with one dot per event", () => {
|
|
31
|
+
const model = computeDots(LOG);
|
|
32
|
+
expect(model.rows).toHaveLength(3);
|
|
33
|
+
expect(model.dots).toHaveLength(6);
|
|
34
|
+
for (const row of model.rows) {
|
|
35
|
+
const dots = model.dots.slice(row.dotStart, row.dotEnd);
|
|
36
|
+
expect(dots).toHaveLength(row.eventCount);
|
|
37
|
+
expect(dots.every((dot) => dot.caseId === row.caseId && dot.rowIndex === row.index)).toBe(
|
|
38
|
+
true,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("sorts by start (default), end, duration and start_day, ascending", () => {
|
|
44
|
+
expect(ids(computeDots(LOG).rows)).toEqual(["c-long", "c-short", "c-late"]);
|
|
45
|
+
expect(ids(computeDots(LOG, { sort: "end" }).rows)).toEqual(["c-short", "c-late", "c-long"]);
|
|
46
|
+
expect(ids(computeDots(LOG, { sort: "duration" }).rows)).toEqual([
|
|
47
|
+
"c-short",
|
|
48
|
+
"c-late",
|
|
49
|
+
"c-long",
|
|
50
|
+
]);
|
|
51
|
+
expect(ids(computeDots(LOG, { sort: "start_day" }).rows)).toEqual([
|
|
52
|
+
"c-short",
|
|
53
|
+
"c-long",
|
|
54
|
+
"c-late",
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("carries first/last activity, extent and event count per row", () => {
|
|
59
|
+
const row = computeDots(LOG).rows[0]!;
|
|
60
|
+
expect(row).toMatchObject({
|
|
61
|
+
caseId: "c-long",
|
|
62
|
+
firstActivity: "A",
|
|
63
|
+
lastActivity: "B",
|
|
64
|
+
start: MONDAY + 9 * HOUR,
|
|
65
|
+
end: MONDAY + 2 * DAY + 10 * HOUR,
|
|
66
|
+
duration: 2 * DAY + HOUR,
|
|
67
|
+
eventCount: 2,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("drops events with unparsable timestamps, and cases left with none", () => {
|
|
72
|
+
const model = computeDots({
|
|
73
|
+
events: [
|
|
74
|
+
{ caseId: "ok", activity: "A", timestamp: MONDAY },
|
|
75
|
+
{ caseId: "ok", activity: "B", timestamp: "not a date" },
|
|
76
|
+
{ caseId: "bad", activity: "A", timestamp: "nope" },
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
expect(ids(model.rows)).toEqual(["ok"]);
|
|
80
|
+
expect(model.dots).toHaveLength(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("yields an empty model with a non-zero domain for an empty log", () => {
|
|
84
|
+
const model = computeDots({ events: [] });
|
|
85
|
+
expect(model.rows).toEqual([]);
|
|
86
|
+
expect(model.domain[1]).toBeGreaterThan(model.domain[0]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("computeDots — x modes", () => {
|
|
91
|
+
it("absolute places the event start and spans the min/max", () => {
|
|
92
|
+
const model = computeDots(LOG);
|
|
93
|
+
expect(model.domain).toEqual([MONDAY + 9 * HOUR, MONDAY + 2 * DAY + 10 * HOUR]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("relative subtracts the case start from every event", () => {
|
|
97
|
+
const model = computeDots(LOG, { x: "relative" });
|
|
98
|
+
const long = model.rows.find((row) => row.caseId === "c-long")!;
|
|
99
|
+
expect(model.dots.slice(long.dotStart, long.dotEnd).map((dot) => dot.x)).toEqual([
|
|
100
|
+
0,
|
|
101
|
+
2 * DAY + HOUR,
|
|
102
|
+
]);
|
|
103
|
+
expect(model.domain[0]).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("relative_day is UTC time of day on a fixed 24 h domain", () => {
|
|
107
|
+
const model = computeDots(LOG, { x: "relative_day" });
|
|
108
|
+
expect(model.domain).toEqual([0, DAY]);
|
|
109
|
+
const late = model.rows.find((row) => row.caseId === "c-late")!;
|
|
110
|
+
expect(model.dots.slice(late.dotStart, late.dotEnd).map((dot) => dot.x)).toEqual([
|
|
111
|
+
20 * HOUR,
|
|
112
|
+
2 * HOUR,
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("relative_week is UTC time since Monday 00:00 on a fixed 7 d domain", () => {
|
|
117
|
+
const model = computeDots(LOG, { x: "relative_week" });
|
|
118
|
+
expect(model.domain).toEqual([0, 7 * DAY]);
|
|
119
|
+
expect(model.dots[0]!.x).toBe(9 * HOUR);
|
|
120
|
+
expect(model.dots[1]!.x).toBe(2 * DAY + 10 * HOUR);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("casesInBrush", () => {
|
|
125
|
+
it("returns covered case ids in row order, requiring a dot inside the x range", () => {
|
|
126
|
+
const model = computeDots(LOG);
|
|
127
|
+
// All rows, the whole domain.
|
|
128
|
+
expect(casesInBrush(model, [2, 0], model.domain)).toEqual(ids(model.rows));
|
|
129
|
+
// A stretch of time where only c-short and c-late have events.
|
|
130
|
+
expect(
|
|
131
|
+
casesInBrush(model, [0, 2], [MONDAY + DAY + 5 * HOUR, MONDAY + DAY + 21 * HOUR]),
|
|
132
|
+
).toEqual(["c-short", "c-late"]);
|
|
133
|
+
// Rows 0–1 only.
|
|
134
|
+
expect(casesInBrush(model, [0, 1], model.domain)).toEqual(["c-long", "c-short"]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("brushing 200 cases returns all 200 ids in rows order", () => {
|
|
138
|
+
const model = computeDots(generateSyntheticLog({ cases: 260, seed: 5 }));
|
|
139
|
+
const brushed = casesInBrush(model, [0, 199], model.domain);
|
|
140
|
+
expect(brushed).toHaveLength(200);
|
|
141
|
+
expect(brushed).toEqual(ids(model.rows.slice(0, 200)));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("rankCategoryColors", () => {
|
|
146
|
+
it("ranks by count and spends the same budget as activityColorScale", () => {
|
|
147
|
+
const keys = Array.from({ length: 14 }, (_, i) =>
|
|
148
|
+
Array.from({ length: 20 - i }, () => `k${String(i).padStart(2, "0")}`),
|
|
149
|
+
).flat();
|
|
150
|
+
const ranked = rankCategoryColors(keys);
|
|
151
|
+
expect(ranked).toHaveLength(14);
|
|
152
|
+
expect(ranked[0]).toMatchObject({ key: "k00", count: 20, token: "--chart-1" });
|
|
153
|
+
expect(ranked[ACTIVITY_COLOR_SLOTS - 1]!.token).toBe(`--chart-${ACTIVITY_COLOR_SLOTS}`);
|
|
154
|
+
for (const entry of ranked.slice(ACTIVITY_COLOR_SLOTS)) {
|
|
155
|
+
expect(entry).toMatchObject({ token: ACTIVITY_OTHER_TOKEN, pattern: "other" });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("computeDots — scale", () => {
|
|
161
|
+
it("models 5 000 cases × ~20 events", () => {
|
|
162
|
+
const events: EventLog["events"] = [];
|
|
163
|
+
for (let c = 0; c < 5000; c += 1) {
|
|
164
|
+
for (let e = 0; e < 20; e += 1) {
|
|
165
|
+
events.push({
|
|
166
|
+
caseId: `c${c}`,
|
|
167
|
+
activity: `A${e % 9}`,
|
|
168
|
+
timestamp: MONDAY + c * 60_000 + e * HOUR,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const model = computeDots({ events }, { sort: "duration" });
|
|
173
|
+
expect(model.rows).toHaveLength(5000);
|
|
174
|
+
expect(model.dots).toHaveLength(100_000);
|
|
175
|
+
});
|
|
176
|
+
});
|