@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,209 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { abstractGraph } from "./abstract-graph";
|
|
4
|
+
import { discoverGraph } from "./discover-graph";
|
|
5
|
+
import { generateSyntheticLog } from "./fixtures/synthetic-log";
|
|
6
|
+
import fixture from "./fixtures/order-to-cash-small.json";
|
|
7
|
+
import type { EventLog, ProcessGraph } from "./types";
|
|
8
|
+
|
|
9
|
+
const orderToCash = fixture as EventLog;
|
|
10
|
+
const smallGraph = discoverGraph(orderToCash);
|
|
11
|
+
const syntheticGraph = discoverGraph(generateSyntheticLog({ cases: 120, seed: 7 }));
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Everything reachable from the graph's own start activities, walking its transitions.
|
|
15
|
+
* Deliberately written here rather than imported: the acceptance criterion is a claim
|
|
16
|
+
* about the RETURNED graph, so the test must traverse it independently of the code that
|
|
17
|
+
* produced it.
|
|
18
|
+
*/
|
|
19
|
+
function reachableFromStart(graph: ProcessGraph): Set<string> {
|
|
20
|
+
const out = new Map<string, string[]>();
|
|
21
|
+
for (const edge of graph.transitions) {
|
|
22
|
+
const bucket = out.get(edge.source);
|
|
23
|
+
if (bucket === undefined) out.set(edge.source, [edge.target]);
|
|
24
|
+
else bucket.push(edge.target);
|
|
25
|
+
}
|
|
26
|
+
const seen = new Set<string>(Object.keys(graph.startActivities));
|
|
27
|
+
const queue = [...seen];
|
|
28
|
+
for (let head = 0; head < queue.length; head += 1) {
|
|
29
|
+
for (const next of out.get(queue[head] as string) ?? []) {
|
|
30
|
+
if (seen.has(next)) continue;
|
|
31
|
+
seen.add(next);
|
|
32
|
+
queue.push(next);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return seen;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The mirror image: everything that can still get to an end activity. */
|
|
39
|
+
function canReachEnd(graph: ProcessGraph): Set<string> {
|
|
40
|
+
const into = new Map<string, string[]>();
|
|
41
|
+
for (const edge of graph.transitions) {
|
|
42
|
+
const bucket = into.get(edge.target);
|
|
43
|
+
if (bucket === undefined) into.set(edge.target, [edge.source]);
|
|
44
|
+
else bucket.push(edge.source);
|
|
45
|
+
}
|
|
46
|
+
const seen = new Set<string>(Object.keys(graph.endActivities));
|
|
47
|
+
const queue = [...seen];
|
|
48
|
+
for (let head = 0; head < queue.length; head += 1) {
|
|
49
|
+
for (const previous of into.get(queue[head] as string) ?? []) {
|
|
50
|
+
if (seen.has(previous)) continue;
|
|
51
|
+
seen.add(previous);
|
|
52
|
+
queue.push(previous);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return seen;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function expectConnected(graph: ProcessGraph): void {
|
|
59
|
+
const fromStart = reachableFromStart(graph);
|
|
60
|
+
const toEnd = canReachEnd(graph);
|
|
61
|
+
const stranded = graph.activities
|
|
62
|
+
.map((activity) => activity.id)
|
|
63
|
+
.filter((id) => !fromStart.has(id) || !toEnd.has(id));
|
|
64
|
+
expect(stranded).toEqual([]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("abstractGraph connectivity", () => {
|
|
68
|
+
it("keeps every kept activity between a start and an end at 50/50", () => {
|
|
69
|
+
for (const graph of [smallGraph, syntheticGraph]) {
|
|
70
|
+
const reduced = abstractGraph(graph, { activities: 0.5, paths: 0.5 });
|
|
71
|
+
expect(reduced.activities.length).toBeGreaterThan(0);
|
|
72
|
+
expectConnected(reduced);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("holds across the whole slider range, on both fixtures", () => {
|
|
77
|
+
for (const graph of [smallGraph, syntheticGraph]) {
|
|
78
|
+
for (const step of [0, 0.1, 0.25, 0.4, 0.6, 0.75, 0.9, 1]) {
|
|
79
|
+
const reduced = abstractGraph(graph, { activities: step, paths: step });
|
|
80
|
+
expectConnected(reduced);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("holds when the reduction is inverted (the rare activities kept)", () => {
|
|
86
|
+
for (const graph of [smallGraph, syntheticGraph]) {
|
|
87
|
+
for (const step of [0.2, 0.5, 0.8]) {
|
|
88
|
+
expectConnected(abstractGraph(graph, { activities: step, paths: step, invert: true }));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("leaves islands alone when connectivity repair is switched off", () => {
|
|
94
|
+
// Not a defect — `keepConnected: false` is the "show me literally the top N" view.
|
|
95
|
+
// Asserted so the repair cannot be quietly made unconditional.
|
|
96
|
+
const reduced = abstractGraph(syntheticGraph, {
|
|
97
|
+
activities: 0.4,
|
|
98
|
+
paths: 0.1,
|
|
99
|
+
keepConnected: false,
|
|
100
|
+
});
|
|
101
|
+
const fromStart = reachableFromStart(reduced);
|
|
102
|
+
expect(reduced.activities.some((activity) => !fromStart.has(activity.id))).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("abstractGraph is a view, not a recomputation", () => {
|
|
107
|
+
it("returns the input's own statistic objects", () => {
|
|
108
|
+
const reduced = abstractGraph(smallGraph, { activities: 0.5, paths: 0.5 });
|
|
109
|
+
for (const activity of reduced.activities) {
|
|
110
|
+
expect(smallGraph.activities).toContain(activity);
|
|
111
|
+
}
|
|
112
|
+
for (const edge of reduced.transitions) {
|
|
113
|
+
expect(smallGraph.transitions).toContain(edge);
|
|
114
|
+
}
|
|
115
|
+
expect(reduced.totals).toBe(smallGraph.totals);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("does not mutate the graph it was handed", () => {
|
|
119
|
+
const before = structuredClone(smallGraph);
|
|
120
|
+
abstractGraph(smallGraph, { activities: 0.3, paths: 0.3 });
|
|
121
|
+
abstractGraph(smallGraph, { activities: 0.7, paths: 0.2, invert: true });
|
|
122
|
+
expect(smallGraph).toEqual(before);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("reports what it hid, and hides nothing it did not", () => {
|
|
126
|
+
const reduced = abstractGraph(syntheticGraph, { activities: 0.5, paths: 0.5 });
|
|
127
|
+
expect(reduced.hidden.activities).toBe(
|
|
128
|
+
syntheticGraph.activities.length - reduced.activities.length,
|
|
129
|
+
);
|
|
130
|
+
expect(reduced.hidden.paths).toBe(
|
|
131
|
+
syntheticGraph.transitions.length - reduced.transitions.length,
|
|
132
|
+
);
|
|
133
|
+
expect(reduced.hidden.activities).toBeGreaterThan(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("narrows the start/end tallies to kept activities without changing their counts", () => {
|
|
137
|
+
const reduced = abstractGraph(syntheticGraph, { activities: 0.5, paths: 0.5 });
|
|
138
|
+
const kept = new Set(reduced.activities.map((activity) => activity.id));
|
|
139
|
+
for (const [id, count] of Object.entries(reduced.startActivities)) {
|
|
140
|
+
expect(kept.has(id)).toBe(true);
|
|
141
|
+
expect(count).toBe(syntheticGraph.startActivities[id]);
|
|
142
|
+
}
|
|
143
|
+
for (const [id, count] of Object.entries(reduced.endActivities)) {
|
|
144
|
+
expect(kept.has(id)).toBe(true);
|
|
145
|
+
expect(count).toBe(syntheticGraph.endActivities[id]);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("abstractGraph identity", () => {
|
|
151
|
+
it("is the identity at { activities: 1, paths: 1 } on both fixtures", () => {
|
|
152
|
+
for (const graph of [smallGraph, syntheticGraph]) {
|
|
153
|
+
const { hidden, ...rest } = abstractGraph(graph, { activities: 1, paths: 1 });
|
|
154
|
+
expect(hidden).toEqual({ activities: 0, paths: 0 });
|
|
155
|
+
expect(rest).toEqual(graph);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("is the identity at { activities: 1, paths: 1 } with every other option too", () => {
|
|
160
|
+
for (const options of [
|
|
161
|
+
{ keepConnected: false },
|
|
162
|
+
{ invert: true },
|
|
163
|
+
{ invert: true, keepConnected: false },
|
|
164
|
+
]) {
|
|
165
|
+
const { hidden, ...rest } = abstractGraph(smallGraph, {
|
|
166
|
+
activities: 1,
|
|
167
|
+
paths: 1,
|
|
168
|
+
...options,
|
|
169
|
+
});
|
|
170
|
+
expect(hidden).toEqual({ activities: 0, paths: 0 });
|
|
171
|
+
expect(rest).toEqual(smallGraph);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("is deterministic — the same input twice gives the same reduced view", () => {
|
|
176
|
+
const first = abstractGraph(syntheticGraph, { activities: 0.35, paths: 0.35 });
|
|
177
|
+
const second = abstractGraph(syntheticGraph, { activities: 0.35, paths: 0.35 });
|
|
178
|
+
expect(second).toEqual(first);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("abstractGraph ranking", () => {
|
|
183
|
+
it("keeps the activities most cases touch, ranked by `cases` not `instances`", () => {
|
|
184
|
+
const reduced = abstractGraph(smallGraph, { activities: 0.5, paths: 1, keepConnected: false });
|
|
185
|
+
// 8 activities × 0.5, rounded → 4. Counted from the fixture: Check Credit and Create
|
|
186
|
+
// Order touch all 5 cases; Approve Order, Receive Payment, Send Invoice and Ship Order
|
|
187
|
+
// touch 4 each with 4 instances each, so that four-way tie is broken by NAME and the
|
|
188
|
+
// first two of them win. Check Credit has 6 instances to Create Order's 5 — irrelevant
|
|
189
|
+
// here, which is the point: `cases` ranks first, so a looping step cannot buy its way
|
|
190
|
+
// up the list by repeating inside one case.
|
|
191
|
+
expect(reduced.activities.map((activity) => activity.id).sort()).toEqual([
|
|
192
|
+
"Approve Order",
|
|
193
|
+
"Check Credit",
|
|
194
|
+
"Create Order",
|
|
195
|
+
"Receive Payment",
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("never empties the graph, whatever the slider says", () => {
|
|
200
|
+
const reduced = abstractGraph(smallGraph, { activities: 0, paths: 0, keepConnected: false });
|
|
201
|
+
expect(reduced.activities.length).toBe(1);
|
|
202
|
+
expect(reduced.transitions).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("reads a non-finite fraction as `keep everything`", () => {
|
|
206
|
+
const reduced = abstractGraph(smallGraph, { activities: Number.NaN, paths: Number.NaN });
|
|
207
|
+
expect(reduced.hidden).toEqual({ activities: 0, paths: 0 });
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slider-driven graph abstraction — RM-050.
|
|
3
|
+
*
|
|
4
|
+
* Every process-mining product surveyed in the wave-1 analysis exposes the same two
|
|
5
|
+
* controls: an ACTIVITIES slider and a PATHS slider, both of which reduce what is DRAWN
|
|
6
|
+
* without touching what was MEASURED. That rule is the whole point of this module — a
|
|
7
|
+
* reader who drags a slider is filtering a view, not re-running an analysis, so the
|
|
8
|
+
* numbers under their cursor must not move.
|
|
9
|
+
*
|
|
10
|
+
* Two invariants follow, and both are asserted in `abstract-graph.test.ts`:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Statistics are never recomputed.** The returned graph reuses the very same
|
|
13
|
+
* {@link ActivityStats} and {@link TransitionStats} OBJECTS the input carried — this
|
|
14
|
+
* function filters arrays, it never builds a statistic. Nothing here mutates the input.
|
|
15
|
+
* 2. **Kept nodes stay connected.** With `keepConnected` (the default) every kept activity
|
|
16
|
+
* is reachable from a start activity and can reach an end activity, because a graph
|
|
17
|
+
* with an island in it reads as a broken process rather than a simplified one.
|
|
18
|
+
*
|
|
19
|
+
* Deterministic: every ranking is totally ordered and the repair walks a fixed-cost graph,
|
|
20
|
+
* so the same input always yields the same reduced view.
|
|
21
|
+
*/
|
|
22
|
+
import { EDGE_KEY_SEPARATOR } from "./discover-graph";
|
|
23
|
+
import type { ActivityStats, ProcessGraph, TransitionStats } from "./types";
|
|
24
|
+
|
|
25
|
+
/** Options for {@link abstractGraph}. Both fractions are `0..1` and both are required. */
|
|
26
|
+
export interface AbstractionOptions {
|
|
27
|
+
/** Fraction of activities to KEEP, `0..1`. At least one activity is always kept. */
|
|
28
|
+
activities: number;
|
|
29
|
+
/** Fraction of paths (transitions) to KEEP, `0..1`, over the kept-activity subgraph. */
|
|
30
|
+
paths: number;
|
|
31
|
+
/**
|
|
32
|
+
* Re-add whatever it takes to keep every kept activity reachable from a start activity
|
|
33
|
+
* and able to reach an end activity. Defaults to `true` — an island reads as a broken
|
|
34
|
+
* process, not a simplified one.
|
|
35
|
+
*/
|
|
36
|
+
keepConnected?: boolean;
|
|
37
|
+
/** Hide the MOST frequent instead of the least — the "what is rare here" view. */
|
|
38
|
+
invert?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What {@link abstractGraph} returns: a `ProcessGraph` plus what it left out. */
|
|
42
|
+
export interface AbstractedGraph extends ProcessGraph {
|
|
43
|
+
hidden: {
|
|
44
|
+
/** Activities present in the input graph and absent from this one. */
|
|
45
|
+
activities: number;
|
|
46
|
+
/** Transitions present in the input graph and absent from this one. */
|
|
47
|
+
paths: number;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Key an edge exactly the way discovery keys it, so the two agree by construction. */
|
|
52
|
+
function edgeKey(source: string, target: string): string {
|
|
53
|
+
return `${source}${EDGE_KEY_SEPARATOR}${target}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Code-unit comparison — locale-independent, so the order is the same on every machine. */
|
|
57
|
+
function compareStrings(a: string, b: string): number {
|
|
58
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A non-finite fraction is read as `1` (keep everything) rather than as an error. */
|
|
62
|
+
function clampFraction(value: number): number {
|
|
63
|
+
if (!Number.isFinite(value)) return 1;
|
|
64
|
+
return value < 0 ? 0 : value > 1 ? 1 : value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* How many of `total` a fraction keeps.
|
|
69
|
+
*
|
|
70
|
+
* `Math.round` (half up) rather than floor or ceil, so the midpoint of the slider keeps
|
|
71
|
+
* half the elements on an even count and the larger half on an odd one — and, crucially,
|
|
72
|
+
* `fraction === 1` keeps exactly `total`, which is what makes the identity property hold.
|
|
73
|
+
* `atLeast` is enforced for activities so a reduced graph is never empty; paths pass `0`,
|
|
74
|
+
* because a node-only view is a legitimate thing to ask for.
|
|
75
|
+
*/
|
|
76
|
+
function countToKeep(total: number, fraction: number, atLeast: number): number {
|
|
77
|
+
if (total === 0) return 0;
|
|
78
|
+
const kept = Math.round(total * fraction);
|
|
79
|
+
return kept < atLeast ? Math.min(atLeast, total) : kept;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A minimal binary min-heap over `(node, cost)` — Dijkstra's queue, no dependency. */
|
|
83
|
+
class MinHeap {
|
|
84
|
+
private readonly nodes: string[] = [];
|
|
85
|
+
private readonly costs: number[] = [];
|
|
86
|
+
|
|
87
|
+
get size(): number {
|
|
88
|
+
return this.nodes.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
push(node: string, cost: number): void {
|
|
92
|
+
this.nodes.push(node);
|
|
93
|
+
this.costs.push(cost);
|
|
94
|
+
let i = this.nodes.length - 1;
|
|
95
|
+
while (i > 0) {
|
|
96
|
+
const parent = (i - 1) >> 1;
|
|
97
|
+
if ((this.costs[parent] as number) <= (this.costs[i] as number)) break;
|
|
98
|
+
this.swap(i, parent);
|
|
99
|
+
i = parent;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
pop(): { node: string; cost: number } | undefined {
|
|
104
|
+
if (this.nodes.length === 0) return undefined;
|
|
105
|
+
const node = this.nodes[0] as string;
|
|
106
|
+
const cost = this.costs[0] as number;
|
|
107
|
+
const lastNode = this.nodes.pop() as string;
|
|
108
|
+
const lastCost = this.costs.pop() as number;
|
|
109
|
+
if (this.nodes.length > 0) {
|
|
110
|
+
this.nodes[0] = lastNode;
|
|
111
|
+
this.costs[0] = lastCost;
|
|
112
|
+
let i = 0;
|
|
113
|
+
for (;;) {
|
|
114
|
+
const left = i * 2 + 1;
|
|
115
|
+
const right = left + 1;
|
|
116
|
+
let smallest = i;
|
|
117
|
+
const size = this.nodes.length;
|
|
118
|
+
if (left < size && (this.costs[left] as number) < (this.costs[smallest] as number)) {
|
|
119
|
+
smallest = left;
|
|
120
|
+
}
|
|
121
|
+
if (right < size && (this.costs[right] as number) < (this.costs[smallest] as number)) {
|
|
122
|
+
smallest = right;
|
|
123
|
+
}
|
|
124
|
+
if (smallest === i) break;
|
|
125
|
+
this.swap(i, smallest);
|
|
126
|
+
i = smallest;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { node, cost };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private swap(a: number, b: number): void {
|
|
133
|
+
const node = this.nodes[a] as string;
|
|
134
|
+
this.nodes[a] = this.nodes[b] as string;
|
|
135
|
+
this.nodes[b] = node;
|
|
136
|
+
const cost = this.costs[a] as number;
|
|
137
|
+
this.costs[a] = this.costs[b] as number;
|
|
138
|
+
this.costs[b] = cost;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface Route {
|
|
143
|
+
/** Activities the route uses, including any the reduction had hidden. */
|
|
144
|
+
activities: Set<string>;
|
|
145
|
+
/** Edge keys the route uses. */
|
|
146
|
+
edges: Set<string>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Reduce `graph` to the fraction of activities and paths a reader asked to see.
|
|
151
|
+
*
|
|
152
|
+
* Activities rank by `cases` (how many process instances touch them), ties broken by
|
|
153
|
+
* `instances` then by name; paths rank by `count`, ties broken by `caseCount` then by
|
|
154
|
+
* endpoint names. Ranking on `cases` rather than `instances` is deliberate: an activity
|
|
155
|
+
* executed forty times inside one case is a loop, not a backbone step, and a frequency
|
|
156
|
+
* slider that promoted it would hide the shape of the process.
|
|
157
|
+
*
|
|
158
|
+
* The returned arrays hold the INPUT's own statistic objects, in the input's order.
|
|
159
|
+
* Treat the result as read-only: mutating a returned `ActivityStats` mutates the source
|
|
160
|
+
* graph's, by design — that shared identity is what proves no recomputation happened.
|
|
161
|
+
*/
|
|
162
|
+
export function abstractGraph(graph: ProcessGraph, opts: AbstractionOptions): AbstractedGraph {
|
|
163
|
+
const keepConnected = opts.keepConnected ?? true;
|
|
164
|
+
const invert = opts.invert ?? false;
|
|
165
|
+
const activityFraction = clampFraction(opts.activities);
|
|
166
|
+
const pathFraction = clampFraction(opts.paths);
|
|
167
|
+
|
|
168
|
+
const rankedActivities = [...graph.activities].sort(
|
|
169
|
+
(a, b) => b.cases - a.cases || b.instances - a.instances || compareStrings(a.id, b.id),
|
|
170
|
+
);
|
|
171
|
+
const activityKeepCount = countToKeep(rankedActivities.length, activityFraction, 1);
|
|
172
|
+
const keptActivities = new Set<string>(
|
|
173
|
+
(invert
|
|
174
|
+
? rankedActivities.slice(rankedActivities.length - activityKeepCount)
|
|
175
|
+
: rankedActivities.slice(0, activityKeepCount)
|
|
176
|
+
).map((activity) => activity.id),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const candidateEdges = graph.transitions.filter(
|
|
180
|
+
(edge) => keptActivities.has(edge.source) && keptActivities.has(edge.target),
|
|
181
|
+
);
|
|
182
|
+
const rankedEdges = [...candidateEdges].sort(
|
|
183
|
+
(a, b) =>
|
|
184
|
+
b.count - a.count ||
|
|
185
|
+
b.caseCount - a.caseCount ||
|
|
186
|
+
compareStrings(a.source, b.source) ||
|
|
187
|
+
compareStrings(a.target, b.target),
|
|
188
|
+
);
|
|
189
|
+
const edgeKeepCount = countToKeep(rankedEdges.length, pathFraction, 0);
|
|
190
|
+
const keptEdges = new Set<string>(
|
|
191
|
+
(invert
|
|
192
|
+
? rankedEdges.slice(rankedEdges.length - edgeKeepCount)
|
|
193
|
+
: rankedEdges.slice(0, edgeKeepCount)
|
|
194
|
+
).map((edge) => edgeKey(edge.source, edge.target)),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
if (keepConnected && keptActivities.size > 0) {
|
|
198
|
+
reconnect(graph, keptActivities, keptEdges);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const activities: ActivityStats[] = graph.activities.filter((activity) =>
|
|
202
|
+
keptActivities.has(activity.id),
|
|
203
|
+
);
|
|
204
|
+
const transitions: TransitionStats[] = graph.transitions.filter((edge) =>
|
|
205
|
+
keptEdges.has(edgeKey(edge.source, edge.target)),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
activities,
|
|
210
|
+
transitions,
|
|
211
|
+
startActivities: pick(graph.startActivities, keptActivities),
|
|
212
|
+
endActivities: pick(graph.endActivities, keptActivities),
|
|
213
|
+
// Passed through by reference: totals describe the LOG, and abstraction is a view.
|
|
214
|
+
totals: graph.totals,
|
|
215
|
+
hidden: {
|
|
216
|
+
activities: graph.activities.length - activities.length,
|
|
217
|
+
paths: graph.transitions.length - transitions.length,
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Keep the entries of `record` whose key survived, in the record's own key order. */
|
|
223
|
+
function pick(record: Record<string, number>, keep: ReadonlySet<string>): Record<string, number> {
|
|
224
|
+
const out: Record<string, number> = {};
|
|
225
|
+
for (const name of Object.keys(record)) {
|
|
226
|
+
if (keep.has(name)) out[name] = record[name] as number;
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Repair connectivity in place, growing `keptActivities` / `keptEdges` as little as it can.
|
|
233
|
+
*
|
|
234
|
+
* For every kept activity that no start activity can reach, the cheapest route from a
|
|
235
|
+
* start activity is found by Dijkstra over edge cost `log(maxCount) − log(count)` — the
|
|
236
|
+
* non-negative form of `−log(count)`, which is what Dijkstra requires and which orders
|
|
237
|
+
* paths identically: minimizing a sum of `−log` weights maximizes the PRODUCT of the edge
|
|
238
|
+
* counts along the route, so the one made of the most-travelled edges wins. Every hidden
|
|
239
|
+
* edge on that route is brought back; a mirrored pass over reversed edges does the same
|
|
240
|
+
* for activities that cannot reach an end activity.
|
|
241
|
+
*
|
|
242
|
+
* The roadmap describes this as re-adding "the max-weight edge on a shortest path". One
|
|
243
|
+
* edge is not enough to restore reachability when a route is several hops long, so ALL of
|
|
244
|
+
* the route's hidden edges are re-added — the smallest change that actually holds the
|
|
245
|
+
* invariant the acceptance criterion asserts.
|
|
246
|
+
*
|
|
247
|
+
* Two escalations, both deliberate and both visible through `hidden`:
|
|
248
|
+
*
|
|
249
|
+
* - If NO kept activity is a start (or end) activity, the busiest one is brought back —
|
|
250
|
+
* otherwise there is no anchor to connect to and the whole graph is an island.
|
|
251
|
+
* - If a route exists only through a hidden activity, that activity is brought back too.
|
|
252
|
+
* The alternative is to leave the node stranded. Both escalations only ever ADD, so the
|
|
253
|
+
* guarantee is exactly: an activity connected in `graph` is connected in the result.
|
|
254
|
+
*/
|
|
255
|
+
function reconnect(graph: ProcessGraph, keptActivities: Set<string>, keptEdges: Set<string>): void {
|
|
256
|
+
const forward = new Map<string, TransitionStats[]>();
|
|
257
|
+
const backward = new Map<string, TransitionStats[]>();
|
|
258
|
+
let maxCount = 1;
|
|
259
|
+
for (const edge of graph.transitions) {
|
|
260
|
+
if (edge.count > maxCount) maxCount = edge.count;
|
|
261
|
+
index(forward, edge.source, edge);
|
|
262
|
+
index(backward, edge.target, edge);
|
|
263
|
+
}
|
|
264
|
+
const logMax = Math.log(maxCount);
|
|
265
|
+
const cost = (count: number): number => (count > 0 ? logMax - Math.log(count) : logMax);
|
|
266
|
+
|
|
267
|
+
// The two passes are COUPLED: repairing "can reach an end" may bring back an activity
|
|
268
|
+
// that nothing reaches from a start, and vice versa. So they run to a fixpoint rather
|
|
269
|
+
// than once each. Both passes only ever ADD, and the sets are bounded by the input
|
|
270
|
+
// graph, so the loop terminates — the bound is belt-and-braces against a future edit
|
|
271
|
+
// that makes a pass able to remove something.
|
|
272
|
+
const bound = graph.activities.length + graph.transitions.length + 2;
|
|
273
|
+
for (let round = 0; round < bound; round += 1) {
|
|
274
|
+
const before = keptActivities.size + keptEdges.size;
|
|
275
|
+
repairDirection(graph.startActivities, forward, true);
|
|
276
|
+
repairDirection(graph.endActivities, backward, false);
|
|
277
|
+
if (keptActivities.size + keptEdges.size === before) break;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function repairDirection(
|
|
281
|
+
seedCounts: Record<string, number>,
|
|
282
|
+
adjacency: Map<string, TransitionStats[]>,
|
|
283
|
+
downstream: boolean,
|
|
284
|
+
): void {
|
|
285
|
+
const allSeeds = Object.keys(seedCounts);
|
|
286
|
+
if (allSeeds.length === 0) return;
|
|
287
|
+
if (!allSeeds.some((id) => keptActivities.has(id))) anchor(seedCounts, keptActivities);
|
|
288
|
+
const keptSeeds = allSeeds.filter((id) => keptActivities.has(id));
|
|
289
|
+
if (keptSeeds.length === 0) return;
|
|
290
|
+
const reached = spread(keptSeeds, adjacency, downstream);
|
|
291
|
+
|
|
292
|
+
// A stable, deterministic repair order: the graph's own activity order.
|
|
293
|
+
for (const activity of graph.activities) {
|
|
294
|
+
if (!keptActivities.has(activity.id) || reached.has(activity.id)) continue;
|
|
295
|
+
// Escalate in three steps, cheapest first — kept route, then a route through hidden
|
|
296
|
+
// activities, then a route to an anchor the reduction hid entirely. The third step
|
|
297
|
+
// is what an INVERTED reduction needs: keeping only the rare activities can hide
|
|
298
|
+
// every end activity the backbone actually leads to, and then the only honest repair
|
|
299
|
+
// is to bring one of those back.
|
|
300
|
+
const route =
|
|
301
|
+
shortestRoute(keptSeeds, activity.id, adjacency, downstream, true) ??
|
|
302
|
+
shortestRoute(keptSeeds, activity.id, adjacency, downstream, false) ??
|
|
303
|
+
shortestRoute(allSeeds, activity.id, adjacency, downstream, false);
|
|
304
|
+
if (route === undefined) continue; // genuinely unreachable in the FULL graph too.
|
|
305
|
+
for (const id of route.activities) keptActivities.add(id);
|
|
306
|
+
for (const key of route.edges) keptEdges.add(key);
|
|
307
|
+
for (const id of spread([...route.activities], adjacency, downstream)) reached.add(id);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** BFS over the KEPT edges only — which nodes the anchors already reach. */
|
|
312
|
+
function spread(
|
|
313
|
+
seeds: readonly string[],
|
|
314
|
+
adjacency: Map<string, TransitionStats[]>,
|
|
315
|
+
downstream: boolean,
|
|
316
|
+
): Set<string> {
|
|
317
|
+
const seen = new Set<string>(seeds.filter((id) => keptActivities.has(id)));
|
|
318
|
+
const queue = [...seen];
|
|
319
|
+
for (let head = 0; head < queue.length; head += 1) {
|
|
320
|
+
const node = queue[head] as string;
|
|
321
|
+
for (const edge of adjacency.get(node) ?? []) {
|
|
322
|
+
const next = downstream ? edge.target : edge.source;
|
|
323
|
+
if (!keptActivities.has(next)) continue;
|
|
324
|
+
if (!keptEdges.has(edgeKey(edge.source, edge.target))) continue;
|
|
325
|
+
if (seen.has(next)) continue;
|
|
326
|
+
seen.add(next);
|
|
327
|
+
queue.push(next);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return seen;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Cheapest route from any seed to `target`, as the activities and edge keys it uses.
|
|
335
|
+
* `keptOnly` restricts the walk to already-kept activities; the caller retries without
|
|
336
|
+
* it when that fails, which is the second escalation described above.
|
|
337
|
+
*/
|
|
338
|
+
function shortestRoute(
|
|
339
|
+
seeds: readonly string[],
|
|
340
|
+
target: string,
|
|
341
|
+
adjacency: Map<string, TransitionStats[]>,
|
|
342
|
+
downstream: boolean,
|
|
343
|
+
keptOnly: boolean,
|
|
344
|
+
): Route | undefined {
|
|
345
|
+
const distance = new Map<string, number>();
|
|
346
|
+
const previous = new Map<string, TransitionStats>();
|
|
347
|
+
const settled = new Set<string>();
|
|
348
|
+
const heap = new MinHeap();
|
|
349
|
+
for (const seed of seeds) {
|
|
350
|
+
distance.set(seed, 0);
|
|
351
|
+
heap.push(seed, 0);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
while (heap.size > 0) {
|
|
355
|
+
const top = heap.pop() as { node: string; cost: number };
|
|
356
|
+
if (settled.has(top.node)) continue;
|
|
357
|
+
settled.add(top.node);
|
|
358
|
+
if (top.node === target) break;
|
|
359
|
+
for (const edge of adjacency.get(top.node) ?? []) {
|
|
360
|
+
const next = downstream ? edge.target : edge.source;
|
|
361
|
+
if (next === top.node) continue; // a self-loop cannot shorten anything.
|
|
362
|
+
if (keptOnly && !keptActivities.has(next)) continue;
|
|
363
|
+
const candidate = top.cost + cost(edge.count);
|
|
364
|
+
const known = distance.get(next);
|
|
365
|
+
// A strict improvement only, so a tie keeps the first route found — and the walk
|
|
366
|
+
// order is the graph's own deterministic edge order.
|
|
367
|
+
if (known !== undefined && known <= candidate) continue;
|
|
368
|
+
distance.set(next, candidate);
|
|
369
|
+
previous.set(next, edge);
|
|
370
|
+
heap.push(next, candidate);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!settled.has(target)) return undefined;
|
|
375
|
+
const route: Route = { activities: new Set([target]), edges: new Set() };
|
|
376
|
+
let cursor = target;
|
|
377
|
+
for (;;) {
|
|
378
|
+
const edge = previous.get(cursor);
|
|
379
|
+
if (edge === undefined) break;
|
|
380
|
+
route.edges.add(edgeKey(edge.source, edge.target));
|
|
381
|
+
route.activities.add(edge.source);
|
|
382
|
+
route.activities.add(edge.target);
|
|
383
|
+
cursor = downstream ? edge.source : edge.target;
|
|
384
|
+
}
|
|
385
|
+
return route;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Bring back the busiest start/end activity when the reduction kept none of them. */
|
|
389
|
+
function anchor(counts: Record<string, number>, kept: Set<string>): void {
|
|
390
|
+
const names = Object.keys(counts);
|
|
391
|
+
if (names.length === 0) return;
|
|
392
|
+
if (names.some((name) => kept.has(name))) return;
|
|
393
|
+
let best = names[0] as string;
|
|
394
|
+
for (const name of names) {
|
|
395
|
+
const value = counts[name] as number;
|
|
396
|
+
const bestValue = counts[best] as number;
|
|
397
|
+
if (value > bestValue || (value === bestValue && compareStrings(name, best) < 0)) best = name;
|
|
398
|
+
}
|
|
399
|
+
kept.add(best);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function index(map: Map<string, TransitionStats[]>, key: string, edge: TransitionStats): void {
|
|
404
|
+
const bucket = map.get(key);
|
|
405
|
+
if (bucket === undefined) map.set(key, [edge]);
|
|
406
|
+
else bucket.push(edge);
|
|
407
|
+
}
|