@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,288 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AbstractionControls — the two sliders every process-mining explorer opens with (RM-052,
|
|
5
|
+
* issue #227). Activities/paths abstraction is a VIEW filter, never a re-analysis
|
|
6
|
+
* (`abstractGraph`'s own contract — see `core/abstract-graph.ts`): dragging a slider hides
|
|
7
|
+
* nodes/edges, it never recomputes a statistic.
|
|
8
|
+
*
|
|
9
|
+
* Composed entirely from `@elabs-ai/components-ui` primitives — two `Slider`s, a `Switch`,
|
|
10
|
+
* a `Button` — plus the pure, framework-free `computeAutoAbstraction` heuristic. No local
|
|
11
|
+
* slider/switch/select is authored here (`pnpm process:reuse:check`).
|
|
12
|
+
*
|
|
13
|
+
* ## Controlled, not stateful
|
|
14
|
+
*
|
|
15
|
+
* This component owns no abstraction state of its own — `abstraction` is the current value,
|
|
16
|
+
* `onAbstractionChange` is how it asks for a new one. `useProcessExplorer` is the one place
|
|
17
|
+
* that actually holds the `useState` (see the compound-component "lift state into the
|
|
18
|
+
* provider" convention, `.claude/rules/component-api.md`).
|
|
19
|
+
*
|
|
20
|
+
* ## "Auto" reads the graph, not just the sliders
|
|
21
|
+
*
|
|
22
|
+
* The pre-abstraction activity total is derived, not passed as a separate prop:
|
|
23
|
+
* `graph.activities.length + hiddenCounts.activities` — the CURRENT (already-abstracted)
|
|
24
|
+
* graph's kept count plus what it currently hides always sums back to the full graph's
|
|
25
|
+
* activity count, so "Auto" needs no additional field on `useProcessExplorer`.
|
|
26
|
+
*/
|
|
27
|
+
import { forwardRef, useCallback, useId, type HTMLAttributes } from "react";
|
|
28
|
+
import { Sparkles } from "lucide-react";
|
|
29
|
+
import { Button, Label, Slider, Switch, useLocale } from "@elabs-ai/components-ui";
|
|
30
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
31
|
+
import type { AbstractionOptions } from "../core/abstract-graph";
|
|
32
|
+
import type { ProcessGraph } from "../core/types";
|
|
33
|
+
import { computeAutoAbstraction } from "./auto-abstraction";
|
|
34
|
+
|
|
35
|
+
const TICKS = [25, 50, 75, 100] as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The range every tick's percent value is drawn from — the SAME `min`/`max` handed to both
|
|
39
|
+
* `Slider`s below (`min={SLIDER_MIN} max={SLIDER_MAX}`), so a tick's rail offset is always
|
|
40
|
+
* derived from the slider's own range rather than a second, independently hardcoded `0..100`
|
|
41
|
+
* (#355). Today they coincide numerically (percent === offset), but a future non-percent range
|
|
42
|
+
* stays correct because both read this one pair of constants.
|
|
43
|
+
*/
|
|
44
|
+
const SLIDER_MIN = 0;
|
|
45
|
+
const SLIDER_MAX = 100;
|
|
46
|
+
|
|
47
|
+
/** A tick's position along the rail, as a `0..100` offset — see {@link SLIDER_MIN}. */
|
|
48
|
+
function tickOffsetPercent(percent: number): number {
|
|
49
|
+
return ((percent - SLIDER_MIN) / (SLIDER_MAX - SLIDER_MIN)) * 100;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How far above the "Auto" activities fraction the paths fraction is set — the roadmap's
|
|
54
|
+
* "paths % = 0.2 above the activities %" (RM-052 round 2, #227, F2 third piece). `abstractGraph`
|
|
55
|
+
* only considers an edge a candidate to keep once BOTH its endpoints survive the activities
|
|
56
|
+
* cut (`core/abstract-graph.ts`'s `candidateEdges` filter), so that candidate pool is already
|
|
57
|
+
* thinner than the full edge set once activities are cut — applying the SAME fraction to it
|
|
58
|
+
* would keep noticeably fewer edges than activities were kept. The offset compensates for
|
|
59
|
+
* that, without a second search over the real graph.
|
|
60
|
+
*/
|
|
61
|
+
const AUTO_PATHS_OFFSET = 0.2;
|
|
62
|
+
|
|
63
|
+
function toPercent(fraction: number): number {
|
|
64
|
+
return Math.round(fraction * 100);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fromPercent(percent: number): number {
|
|
68
|
+
return percent / 100;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface AbstractionControlsProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
72
|
+
/** The current abstraction — both fractions, `invert`, and (read-only here) `keepConnected`. */
|
|
73
|
+
abstraction: AbstractionOptions;
|
|
74
|
+
/** Called with a PARTIAL patch — mirrors `useProcessExplorer`'s own `setAbstraction`. */
|
|
75
|
+
onAbstractionChange(next: Partial<AbstractionOptions>): void;
|
|
76
|
+
/** The CURRENT (already-abstracted) graph — only `activities.length` is read. */
|
|
77
|
+
graph: Pick<ProcessGraph, "activities">;
|
|
78
|
+
/** What the current abstraction hides, straight from `AbstractedGraph.hidden`. */
|
|
79
|
+
hiddenCounts: { activities: number; paths: number };
|
|
80
|
+
/** Node budget the "Auto" button searches for. Default `25`. */
|
|
81
|
+
autoMaxActivities?: number;
|
|
82
|
+
/** Accessible name for the control group. Default from locale. */
|
|
83
|
+
label?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* One clickable percentage tick — sets its slider directly rather than requiring a drag.
|
|
88
|
+
* Positioned on a `relative` rail (never spread by flexbox, #355): its centre sits at its own
|
|
89
|
+
* value's fraction of the rail, matching the `Slider` track's coordinate system directly below
|
|
90
|
+
* it. The interior ticks centre on their value (`-translate-x-1/2`); the `100%` tick right-aligns
|
|
91
|
+
* to the rail's own edge instead (`-translate-x-full`) so it is never clipped.
|
|
92
|
+
*/
|
|
93
|
+
function Tick({
|
|
94
|
+
percent,
|
|
95
|
+
active,
|
|
96
|
+
onSelect,
|
|
97
|
+
}: {
|
|
98
|
+
percent: number;
|
|
99
|
+
active: boolean;
|
|
100
|
+
onSelect(percent: number): void;
|
|
101
|
+
}) {
|
|
102
|
+
const offsetPercent = tickOffsetPercent(percent);
|
|
103
|
+
return (
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
data-slot="abstraction-controls-tick"
|
|
107
|
+
aria-pressed={active}
|
|
108
|
+
onClick={() => onSelect(percent)}
|
|
109
|
+
style={{ left: `${offsetPercent}%` }}
|
|
110
|
+
className={cn(
|
|
111
|
+
"focus-ring absolute top-0 rounded-sm text-meta text-muted-foreground transition-colors duration-fast ease-standard hover:text-foreground",
|
|
112
|
+
offsetPercent <= 0
|
|
113
|
+
? "translate-x-0"
|
|
114
|
+
: offsetPercent >= 100
|
|
115
|
+
? "-translate-x-full"
|
|
116
|
+
: "-translate-x-1/2",
|
|
117
|
+
active && "font-semibold text-foreground",
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
{percent}%
|
|
121
|
+
</button>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const AbstractionControls = forwardRef<HTMLDivElement, AbstractionControlsProps>(
|
|
126
|
+
function AbstractionControls(
|
|
127
|
+
{
|
|
128
|
+
abstraction,
|
|
129
|
+
onAbstractionChange,
|
|
130
|
+
graph,
|
|
131
|
+
hiddenCounts,
|
|
132
|
+
autoMaxActivities = 25,
|
|
133
|
+
label,
|
|
134
|
+
className,
|
|
135
|
+
...props
|
|
136
|
+
},
|
|
137
|
+
ref,
|
|
138
|
+
) {
|
|
139
|
+
const { t } = useLocale();
|
|
140
|
+
const activitiesId = useId();
|
|
141
|
+
const pathsId = useId();
|
|
142
|
+
|
|
143
|
+
const activitiesPercent = toPercent(abstraction.activities);
|
|
144
|
+
const pathsPercent = toPercent(abstraction.paths);
|
|
145
|
+
|
|
146
|
+
const setActivitiesPercent = useCallback(
|
|
147
|
+
(percent: number) => onAbstractionChange({ activities: fromPercent(percent) }),
|
|
148
|
+
[onAbstractionChange],
|
|
149
|
+
);
|
|
150
|
+
const setPathsPercent = useCallback(
|
|
151
|
+
(percent: number) => onAbstractionChange({ paths: fromPercent(percent) }),
|
|
152
|
+
[onAbstractionChange],
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const handleAuto = useCallback(() => {
|
|
156
|
+
const totalActivities = graph.activities.length + hiddenCounts.activities;
|
|
157
|
+
const result = computeAutoAbstraction(totalActivities, {
|
|
158
|
+
maxActivities: autoMaxActivities,
|
|
159
|
+
});
|
|
160
|
+
onAbstractionChange({
|
|
161
|
+
activities: result.activities,
|
|
162
|
+
paths: Math.min(1, result.activities + AUTO_PATHS_OFFSET),
|
|
163
|
+
});
|
|
164
|
+
}, [graph.activities.length, hiddenCounts.activities, autoMaxActivities, onAbstractionChange]);
|
|
165
|
+
|
|
166
|
+
// Two independently-pluralized fragments, joined — `t()` selects its plural category
|
|
167
|
+
// from a single `count` var, so one activities count and one paths count cannot share
|
|
168
|
+
// a single PluralMessage. Composing two already-localized fragments is the standard
|
|
169
|
+
// way around that (see `resolveMessage` in locale-provider.tsx).
|
|
170
|
+
const hiddenSummary = `${t("process.abstractionControls.hiddenActivities", {
|
|
171
|
+
count: hiddenCounts.activities,
|
|
172
|
+
})} · ${t("process.abstractionControls.hiddenPaths", { count: hiddenCounts.paths })}`;
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div
|
|
176
|
+
ref={ref}
|
|
177
|
+
data-slot="abstraction-controls"
|
|
178
|
+
role="group"
|
|
179
|
+
aria-label={label ?? t("process.abstractionControls.label")}
|
|
180
|
+
className={cn("flex flex-col gap-4", className)}
|
|
181
|
+
{...props}
|
|
182
|
+
>
|
|
183
|
+
<div
|
|
184
|
+
data-slot="abstraction-controls-activities"
|
|
185
|
+
role="group"
|
|
186
|
+
aria-labelledby={`${activitiesId}-label`}
|
|
187
|
+
className="flex flex-col gap-1.5"
|
|
188
|
+
>
|
|
189
|
+
<div className="flex items-center justify-between gap-2">
|
|
190
|
+
<Label id={`${activitiesId}-label`} htmlFor={activitiesId} className="text-body">
|
|
191
|
+
{t("process.abstractionControls.activities")}
|
|
192
|
+
</Label>
|
|
193
|
+
<span className="text-meta text-muted-foreground tabular-nums">
|
|
194
|
+
{activitiesPercent}%
|
|
195
|
+
</span>
|
|
196
|
+
</div>
|
|
197
|
+
<Slider
|
|
198
|
+
id={activitiesId}
|
|
199
|
+
min={SLIDER_MIN}
|
|
200
|
+
max={SLIDER_MAX}
|
|
201
|
+
step={1}
|
|
202
|
+
value={[activitiesPercent]}
|
|
203
|
+
onValueChange={([next]) => setActivitiesPercent(next ?? activitiesPercent)}
|
|
204
|
+
aria-label={t("process.abstractionControls.activities")}
|
|
205
|
+
/>
|
|
206
|
+
<div className="relative h-4">
|
|
207
|
+
{TICKS.map((percent) => (
|
|
208
|
+
<Tick
|
|
209
|
+
key={percent}
|
|
210
|
+
percent={percent}
|
|
211
|
+
active={activitiesPercent === percent}
|
|
212
|
+
onSelect={setActivitiesPercent}
|
|
213
|
+
/>
|
|
214
|
+
))}
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div
|
|
219
|
+
data-slot="abstraction-controls-paths"
|
|
220
|
+
role="group"
|
|
221
|
+
aria-labelledby={`${pathsId}-label`}
|
|
222
|
+
className="flex flex-col gap-1.5"
|
|
223
|
+
>
|
|
224
|
+
<div className="flex items-center justify-between gap-2">
|
|
225
|
+
<Label id={`${pathsId}-label`} htmlFor={pathsId} className="text-body">
|
|
226
|
+
{t("process.abstractionControls.paths")}
|
|
227
|
+
</Label>
|
|
228
|
+
<span className="text-meta text-muted-foreground tabular-nums">{pathsPercent}%</span>
|
|
229
|
+
</div>
|
|
230
|
+
<Slider
|
|
231
|
+
id={pathsId}
|
|
232
|
+
min={SLIDER_MIN}
|
|
233
|
+
max={SLIDER_MAX}
|
|
234
|
+
step={1}
|
|
235
|
+
value={[pathsPercent]}
|
|
236
|
+
onValueChange={([next]) => setPathsPercent(next ?? pathsPercent)}
|
|
237
|
+
aria-label={t("process.abstractionControls.paths")}
|
|
238
|
+
/>
|
|
239
|
+
<div className="relative h-4">
|
|
240
|
+
{TICKS.map((percent) => (
|
|
241
|
+
<Tick
|
|
242
|
+
key={percent}
|
|
243
|
+
percent={percent}
|
|
244
|
+
active={pathsPercent === percent}
|
|
245
|
+
onSelect={setPathsPercent}
|
|
246
|
+
/>
|
|
247
|
+
))}
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<div
|
|
252
|
+
data-slot="abstraction-controls-footer"
|
|
253
|
+
className="flex items-center justify-between gap-3"
|
|
254
|
+
>
|
|
255
|
+
<div className="flex items-center gap-2">
|
|
256
|
+
<Switch
|
|
257
|
+
id={`${activitiesId}-invert`}
|
|
258
|
+
checked={abstraction.invert ?? false}
|
|
259
|
+
onCheckedChange={(checked) => onAbstractionChange({ invert: checked })}
|
|
260
|
+
/>
|
|
261
|
+
<Label htmlFor={`${activitiesId}-invert`} className="text-body">
|
|
262
|
+
{t("process.abstractionControls.invert")}
|
|
263
|
+
</Label>
|
|
264
|
+
</div>
|
|
265
|
+
<Button
|
|
266
|
+
type="button"
|
|
267
|
+
variant="outline"
|
|
268
|
+
size="sm"
|
|
269
|
+
onClick={handleAuto}
|
|
270
|
+
data-slot="abstraction-controls-auto"
|
|
271
|
+
>
|
|
272
|
+
<Sparkles aria-hidden="true" />
|
|
273
|
+
{t("process.abstractionControls.auto")}
|
|
274
|
+
</Button>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
<p
|
|
278
|
+
data-slot="abstraction-controls-hidden-summary"
|
|
279
|
+
role="status"
|
|
280
|
+
aria-live="polite"
|
|
281
|
+
className="text-meta text-muted-foreground"
|
|
282
|
+
>
|
|
283
|
+
{hiddenSummary}
|
|
284
|
+
</p>
|
|
285
|
+
</div>
|
|
286
|
+
);
|
|
287
|
+
},
|
|
288
|
+
);
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { abstractGraph } from "../core/abstract-graph";
|
|
3
|
+
import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
|
|
4
|
+
import { discoverGraph } from "../core/discover-graph";
|
|
5
|
+
import type { ActivityStats, DurationStats, ProcessGraph, TransitionStats } from "../core/types";
|
|
6
|
+
import { computeAutoAbstraction } from "./auto-abstraction";
|
|
7
|
+
|
|
8
|
+
function keptCount(total: number, fraction: number): number {
|
|
9
|
+
return Math.max(1, Math.round(total * fraction));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ZERO_DURATION: DurationStats = {
|
|
13
|
+
min: 0,
|
|
14
|
+
max: 0,
|
|
15
|
+
mean: 0,
|
|
16
|
+
median: 0,
|
|
17
|
+
p90: 0,
|
|
18
|
+
sum: 0,
|
|
19
|
+
trimmedMean: 0,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A single, unbranched activity chain — `size` activities, `size - 1` directly-follows
|
|
24
|
+
* edges, strictly decreasing frequency along the chain, NO skip/bypass paths, no loops.
|
|
25
|
+
* Neither shipped fixture reaches this module's own real >25-activity failure mode
|
|
26
|
+
* (`generateSyntheticLog`'s vocabulary is 11 activities; `generateBpi2012Subset`'s is 19),
|
|
27
|
+
* so this is a purpose-built minimal graph for it — mirrors the `largeGraph()` helper in
|
|
28
|
+
* `process-map.stories.tsx`, but WITHOUT that helper's skip-path edges, which is exactly
|
|
29
|
+
* the property this fixture needs: a bypass edge would let `abstractGraph`'s connectivity
|
|
30
|
+
* repair route AROUND a dropped activity instead of being forced to restore it.
|
|
31
|
+
*/
|
|
32
|
+
function chainGraph(size: number): ProcessGraph {
|
|
33
|
+
const ids = Array.from({ length: size }, (_, i) => `Step ${String(i + 1).padStart(2, "0")}`);
|
|
34
|
+
const activities: ActivityStats[] = ids.map((id, index) => ({
|
|
35
|
+
id,
|
|
36
|
+
label: id,
|
|
37
|
+
instances: size - index,
|
|
38
|
+
cases: size - index,
|
|
39
|
+
isStart: index === 0,
|
|
40
|
+
isEnd: index === size - 1,
|
|
41
|
+
duration: ZERO_DURATION,
|
|
42
|
+
}));
|
|
43
|
+
const transitions: TransitionStats[] = [];
|
|
44
|
+
for (let i = 0; i < ids.length - 1; i += 1) {
|
|
45
|
+
transitions.push({
|
|
46
|
+
source: ids[i]!,
|
|
47
|
+
target: ids[i + 1]!,
|
|
48
|
+
count: size - i,
|
|
49
|
+
caseCount: size - i,
|
|
50
|
+
duration: ZERO_DURATION,
|
|
51
|
+
isSelfLoop: false,
|
|
52
|
+
isBackEdge: false,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
activities,
|
|
57
|
+
transitions,
|
|
58
|
+
startActivities: { [ids[0]!]: size },
|
|
59
|
+
endActivities: { [ids[size - 1]!]: size },
|
|
60
|
+
totals: { cases: size, events: size * 2, variants: 1 },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("computeAutoAbstraction — the bound (terminates in at most maxSteps probes)", () => {
|
|
65
|
+
it("never exceeds maxSteps regardless of how large the graph is", () => {
|
|
66
|
+
const result = computeAutoAbstraction(1000, { maxActivities: 25, maxSteps: 8 });
|
|
67
|
+
expect(result.steps).toBeLessThanOrEqual(8);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("a graph already within budget needs zero probes", () => {
|
|
71
|
+
const result = computeAutoAbstraction(10, { maxActivities: 25 });
|
|
72
|
+
expect(result).toEqual({ activities: 1, steps: 0 });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("a graph too large even at the floor fraction needs exactly one probe", () => {
|
|
76
|
+
const result = computeAutoAbstraction(100_000, { maxActivities: 5, minFraction: 0.05 });
|
|
77
|
+
expect(result).toEqual({ activities: 0.05, steps: 1 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("is deterministic — the same count and options always return the same fraction", () => {
|
|
81
|
+
const first = computeAutoAbstraction(200, { maxActivities: 25 });
|
|
82
|
+
const second = computeAutoAbstraction(200, { maxActivities: 25 });
|
|
83
|
+
expect(second).toEqual(first);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("computeAutoAbstraction — the result actually fits the budget", () => {
|
|
88
|
+
it("the returned fraction keeps at most maxActivities, on a graph that needs the full search budget", () => {
|
|
89
|
+
// 200 activities, budget 25: the floor (5% -> 10 activities) fits, so the search runs
|
|
90
|
+
// its full 8 probes rather than short-circuiting on either edge case above.
|
|
91
|
+
const result = computeAutoAbstraction(200, { maxActivities: 25, maxSteps: 8 });
|
|
92
|
+
expect(result.steps).toBe(8);
|
|
93
|
+
expect(keptCount(200, result.activities)).toBeLessThanOrEqual(25);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("converges toward the LARGEST fitting fraction, not the smallest (60-activity graph)", () => {
|
|
97
|
+
const coarse = computeAutoAbstraction(60, { maxActivities: 10, maxSteps: 2 });
|
|
98
|
+
const fine = computeAutoAbstraction(60, { maxActivities: 10, maxSteps: 8 });
|
|
99
|
+
expect(keptCount(60, coarse.activities)).toBeLessThanOrEqual(10);
|
|
100
|
+
expect(keptCount(60, fine.activities)).toBeLessThanOrEqual(10);
|
|
101
|
+
// More probes never do WORSE than fewer — the search only tightens toward the budget,
|
|
102
|
+
// it never wanders away from a fraction it already confirmed fits.
|
|
103
|
+
expect(fine.activities).toBeGreaterThanOrEqual(coarse.activities);
|
|
104
|
+
// And it isn't the degenerate "smallest fraction that fits" reading: the floor (0.05 ->
|
|
105
|
+
// 3 activities) is far below what an 8-step search settles on for a 10-activity budget.
|
|
106
|
+
expect(keptCount(60, fine.activities)).toBeGreaterThan(3);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("computeAutoAbstraction — a real, large fixture (the roadmap's 13 000-case log)", () => {
|
|
111
|
+
it("handles the 13k-case synthetic fixture's discovered graph without hanging or exceeding the bound", () => {
|
|
112
|
+
const log = generateSyntheticLog({ cases: 13_000, seed: 1 });
|
|
113
|
+
const graph = discoverGraph(log);
|
|
114
|
+
const result = computeAutoAbstraction(graph.activities.length, {
|
|
115
|
+
maxActivities: 25,
|
|
116
|
+
maxSteps: 8,
|
|
117
|
+
});
|
|
118
|
+
expect(result.steps).toBeLessThanOrEqual(8);
|
|
119
|
+
expect(keptCount(graph.activities.length, result.activities)).toBeLessThanOrEqual(25);
|
|
120
|
+
// The synthetic order-to-cash vocabulary is small (8 activities) — already inside any
|
|
121
|
+
// reasonable budget, so "Auto" is correctly a no-op here. This locks that no-op shape;
|
|
122
|
+
// it does NOT exercise the search against a graph that genuinely exceeds the budget —
|
|
123
|
+
// that is what the two describe blocks below are for.
|
|
124
|
+
expect(graph.activities.length).toBeLessThanOrEqual(25);
|
|
125
|
+
expect(result).toEqual({ activities: 1, steps: 0 });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("computeAutoAbstraction — a fixture that actually exceeds the budget (RM-052 round 2, #227, F2)", () => {
|
|
130
|
+
it("on a 30-activity chain, the naive prediction fits maxActivities exactly", () => {
|
|
131
|
+
// This is the module's own promise, restated against a real >25-activity graph rather
|
|
132
|
+
// than the always-under-budget fixture above. It is NOT a claim about `abstractGraph`'s
|
|
133
|
+
// real output — see the next describe block for why those two numbers diverge here.
|
|
134
|
+
const graph = chainGraph(30);
|
|
135
|
+
const result = computeAutoAbstraction(graph.activities.length, {
|
|
136
|
+
maxActivities: 10,
|
|
137
|
+
maxSteps: 8,
|
|
138
|
+
});
|
|
139
|
+
expect(keptCount(graph.activities.length, result.activities)).toBeLessThanOrEqual(10);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("computeAutoAbstraction — known limitation: connectivity repair can blow the naive budget (RM-052 round 2, #227, F2)", () => {
|
|
144
|
+
// `computeAutoAbstraction` predicts a fraction from activity COUNT alone; it has no
|
|
145
|
+
// visibility into the graph's TOPOLOGY. `abstractGraph`'s default `keepConnected: true`
|
|
146
|
+
// adds activities back to restore reachability to a start/end activity that truncation
|
|
147
|
+
// would otherwise strand. On a graph with no bypass edges — a strict chain, where every
|
|
148
|
+
// activity's only path to the end activity runs through every activity after it —
|
|
149
|
+
// dropping ANY suffix forces the entire remainder back in, so the real kept count can
|
|
150
|
+
// land far above what the naive prediction promised. This is a documented, accepted
|
|
151
|
+
// best-effort gap (see the module docblock), not a bug this test is asserting has been
|
|
152
|
+
// fixed — it locks the exact divergence so a future change to either `computeAutoAbstraction`
|
|
153
|
+
// or `abstractGraph`'s reconnection heuristic must consciously re-measure it.
|
|
154
|
+
it("keepConnected:true reconnects the whole chain — the real kept count blows past maxActivities", () => {
|
|
155
|
+
const graph = chainGraph(30);
|
|
156
|
+
const result = computeAutoAbstraction(graph.activities.length, {
|
|
157
|
+
maxActivities: 10,
|
|
158
|
+
maxSteps: 8,
|
|
159
|
+
});
|
|
160
|
+
expect(keptCount(graph.activities.length, result.activities)).toBeLessThanOrEqual(10);
|
|
161
|
+
|
|
162
|
+
const abstracted = abstractGraph(graph, {
|
|
163
|
+
activities: result.activities,
|
|
164
|
+
paths: 1,
|
|
165
|
+
keepConnected: true,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// The naive prediction said 10 activities would remain; connectivity repair on a
|
|
169
|
+
// graph with no bypass paths instead restores every activity in the chain.
|
|
170
|
+
expect(abstracted.activities.length).toBe(30);
|
|
171
|
+
expect(abstracted.activities.length).toBeGreaterThan(10);
|
|
172
|
+
expect(abstracted.hidden.activities).toBe(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("keepConnected:false matches the naive prediction exactly — isolating connectivity repair as the sole cause", () => {
|
|
176
|
+
const graph = chainGraph(30);
|
|
177
|
+
const result = computeAutoAbstraction(graph.activities.length, {
|
|
178
|
+
maxActivities: 10,
|
|
179
|
+
maxSteps: 8,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const abstracted = abstractGraph(graph, {
|
|
183
|
+
activities: result.activities,
|
|
184
|
+
paths: 1,
|
|
185
|
+
keepConnected: false,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// With reconnection disabled, the real result matches this module's own naive mirror
|
|
189
|
+
// exactly — proving the divergence above comes SPECIFICALLY from `keepConnected`'s
|
|
190
|
+
// reachability repair, not from some more general inaccuracy in the heuristic.
|
|
191
|
+
expect(abstracted.activities.length).toBe(
|
|
192
|
+
keptCount(graph.activities.length, result.activities),
|
|
193
|
+
);
|
|
194
|
+
expect(abstracted.activities.length).toBeLessThanOrEqual(10);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto abstraction heuristic — RM-052 (issue #227).
|
|
3
|
+
*
|
|
4
|
+
* The "Auto" button on {@link AbstractionControls} picks an activities fraction that fits
|
|
5
|
+
* a node budget, so a reader opens a busy graph and gets a readable one in one click
|
|
6
|
+
* instead of hand-dragging a slider. This module is pure and framework-free on purpose —
|
|
7
|
+
* it is a small, independently testable piece of judgment, not a UI concern.
|
|
8
|
+
*
|
|
9
|
+
* ## The heuristic
|
|
10
|
+
*
|
|
11
|
+
* "Smallest activities % whose graph fits the budget" is the phrase this feature is
|
|
12
|
+
* usually described by, but taken literally it is degenerate: a fraction near 0 always
|
|
13
|
+
* fits any budget, so the "smallest fraction" reading is trivially satisfied by hiding
|
|
14
|
+
* almost everything. What a reader actually wants from "Auto" is the OPPOSITE end of
|
|
15
|
+
* that: the LARGEST fraction — the most detail — that still fits the budget. This module
|
|
16
|
+
* implements that reading.
|
|
17
|
+
*
|
|
18
|
+
* ## Bounded and terminating, by construction
|
|
19
|
+
*
|
|
20
|
+
* The search is a fixed-iteration binary search over the fraction range `[minFraction,
|
|
21
|
+
* 1]` — `maxSteps` (default 8) iterations, no early-exit convergence check, no
|
|
22
|
+
* data-dependent loop bound. That makes termination a property of the `for` loop itself,
|
|
23
|
+
* not of the input: `computeAutoAbstraction` always does at most `maxSteps` probes of
|
|
24
|
+
* `keptAt(fraction)`, regardless of how many activities the graph has. See
|
|
25
|
+
* `auto-abstraction.test.ts` for the explicit bound assertion.
|
|
26
|
+
*
|
|
27
|
+
* ## Known limitation: this heuristic is topology-blind (RM-052 round 2, #227, F2)
|
|
28
|
+
*
|
|
29
|
+
* `keptActivityCount` predicts how many activities a fraction keeps from the COUNT alone —
|
|
30
|
+
* `round(total * fraction)`. It has no visibility into which activities `abstractGraph`
|
|
31
|
+
* would actually drop, or how they connect. `abstractGraph`'s default `keepConnected: true`
|
|
32
|
+
* adds activities back in to restore reachability to a start/end activity that truncation
|
|
33
|
+
* would otherwise strand — and on a graph with no bypass edges (a strict, unbranched chain,
|
|
34
|
+
* where every activity's only path to the end activity runs through every activity after
|
|
35
|
+
* it), dropping ANY suffix forces the ENTIRE remainder back in. The naive prediction and the
|
|
36
|
+
* real, reconnected result can then diverge by the graph's full size: a 30-activity chain
|
|
37
|
+
* with `maxActivities: 10` predicts a fraction that keeps 10 activities, but
|
|
38
|
+
* `abstractGraph(graph, { activities: fraction, keepConnected: true })` actually keeps all
|
|
39
|
+
* 30 — see `auto-abstraction.test.ts`'s "known limitation" describe block, which measures
|
|
40
|
+
* this exact case and isolates `keepConnected` as the sole cause (turning it off makes the
|
|
41
|
+
* real result match the naive prediction exactly).
|
|
42
|
+
*
|
|
43
|
+
* This is accepted as a best-effort gap, not fixed here: fixing it precisely would mean this
|
|
44
|
+
* module accepting a real `ProcessGraph` and searching against `abstractGraph`'s actual
|
|
45
|
+
* output rather than a plain count — a signature change that would also require updating
|
|
46
|
+
* `AbstractionControls`'s call site (`handleAuto`), which sits outside this round's write-set
|
|
47
|
+
* (RM-052-fix-brief.md / RM-052-tristate-decision.md §10). A future round that wants an exact
|
|
48
|
+
* search should route that signature change through `brand-ui-design-system-architect` (it
|
|
49
|
+
* changes this module's public contract) rather than patching around it here.
|
|
50
|
+
*/
|
|
51
|
+
export interface AutoAbstractionOptions {
|
|
52
|
+
/** Largest number of activities the resulting view should keep. Default `25`. */
|
|
53
|
+
maxActivities?: number;
|
|
54
|
+
/** Maximum fraction candidates probed before returning the best found so far. Default `8`. */
|
|
55
|
+
maxSteps?: number;
|
|
56
|
+
/** The smallest fraction ever offered — "Auto" never hides everything. Default `0.05`. */
|
|
57
|
+
minFraction?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AutoAbstractionResult {
|
|
61
|
+
/** The activities fraction to hand to `setAbstraction`. */
|
|
62
|
+
activities: number;
|
|
63
|
+
/** How many fraction candidates were actually probed — always `<= maxSteps`. */
|
|
64
|
+
steps: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const DEFAULT_MAX_ACTIVITIES = 25;
|
|
68
|
+
const DEFAULT_MAX_STEPS = 8;
|
|
69
|
+
const DEFAULT_MIN_FRACTION = 0.05;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Mirrors `abstractGraph`'s own kept-activity count: `round(total * fraction)`, floored at
|
|
73
|
+
* one activity — `abstractGraph` never hides every activity, so this heuristic must not
|
|
74
|
+
* search for a fraction that would.
|
|
75
|
+
*/
|
|
76
|
+
function keptActivityCount(total: number, fraction: number): number {
|
|
77
|
+
return Math.max(1, Math.round(total * fraction));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Picks the activities fraction "Auto" applies, from a plain activity COUNT — not a graph.
|
|
82
|
+
* Paths are left to `abstractGraph`'s own connectivity repair, and duration/statistic
|
|
83
|
+
* fields never enter the search, so this heuristic's result cannot be perturbed by the
|
|
84
|
+
* discovery layer's own reservoir-sampling nondeterminism. Taking a count rather than a
|
|
85
|
+
* `ProcessGraph` also lets a caller pass the graph's PRE-abstraction total — e.g.
|
|
86
|
+
* `graph.activities.length + hiddenCounts.activities` — without building a throwaway array.
|
|
87
|
+
*/
|
|
88
|
+
export function computeAutoAbstraction(
|
|
89
|
+
totalActivities: number,
|
|
90
|
+
opts: AutoAbstractionOptions = {},
|
|
91
|
+
): AutoAbstractionResult {
|
|
92
|
+
const maxActivities = opts.maxActivities ?? DEFAULT_MAX_ACTIVITIES;
|
|
93
|
+
const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
94
|
+
const minFraction = opts.minFraction ?? DEFAULT_MIN_FRACTION;
|
|
95
|
+
const total = totalActivities;
|
|
96
|
+
|
|
97
|
+
// Already within budget at full detail (or nothing to abstract) — nothing to search for.
|
|
98
|
+
if (total === 0 || keptActivityCount(total, 1) <= maxActivities) {
|
|
99
|
+
return { activities: 1, steps: 0 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Even the floor doesn't fit — hand back the floor. Still bounded (one probe), still
|
|
103
|
+
// the best answer this heuristic can offer for a graph this large.
|
|
104
|
+
if (keptActivityCount(total, minFraction) > maxActivities) {
|
|
105
|
+
return { activities: minFraction, steps: 1 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let lo = minFraction; // largest fraction CONFIRMED to fit the budget so far
|
|
109
|
+
let hi = 1; // smallest fraction confirmed NOT to fit
|
|
110
|
+
let best = minFraction;
|
|
111
|
+
|
|
112
|
+
for (let step = 1; step <= maxSteps; step += 1) {
|
|
113
|
+
const mid = (lo + hi) / 2;
|
|
114
|
+
if (keptActivityCount(total, mid) <= maxActivities) {
|
|
115
|
+
best = mid;
|
|
116
|
+
lo = mid;
|
|
117
|
+
} else {
|
|
118
|
+
hi = mid;
|
|
119
|
+
}
|
|
120
|
+
if (step === maxSteps) {
|
|
121
|
+
return { activities: best, steps: step };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Unreachable — maxSteps is always >= 1 when opts allow it — but keeps the function
|
|
126
|
+
// total for TypeScript's control-flow analysis.
|
|
127
|
+
return { activities: best, steps: maxSteps };
|
|
128
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { AbstractionControls } from "./abstraction-controls";
|
|
2
|
+
export type { AbstractionControlsProps } from "./abstraction-controls";
|
|
3
|
+
export { computeAutoAbstraction } from "./auto-abstraction";
|
|
4
|
+
export type { AutoAbstractionOptions, AutoAbstractionResult } from "./auto-abstraction";
|