@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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/core/index.d.ts +1029 -0
  4. package/dist/core/index.js +1553 -0
  5. package/dist/core/index.js.map +1 -0
  6. package/dist/core/process-worker.js +462 -0
  7. package/dist/core/process-worker.js.map +1 -0
  8. package/dist/index.d.ts +1153 -0
  9. package/dist/index.js +3146 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/test/index.d.ts +196 -0
  12. package/dist/test/index.js +527 -0
  13. package/dist/test/index.js.map +1 -0
  14. package/package.json +80 -0
  15. package/src/abstraction-controls/abstraction-controls-fixtures.ts +86 -0
  16. package/src/abstraction-controls/abstraction-controls.stories.tsx +188 -0
  17. package/src/abstraction-controls/abstraction-controls.test.tsx +226 -0
  18. package/src/abstraction-controls/abstraction-controls.tsx +288 -0
  19. package/src/abstraction-controls/auto-abstraction.test.ts +196 -0
  20. package/src/abstraction-controls/auto-abstraction.ts +128 -0
  21. package/src/abstraction-controls/index.ts +4 -0
  22. package/src/core/abstract-graph.test.ts +209 -0
  23. package/src/core/abstract-graph.ts +407 -0
  24. package/src/core/adapters/csv.test.ts +131 -0
  25. package/src/core/adapters/csv.ts +146 -0
  26. package/src/core/adapters/flat.test.ts +149 -0
  27. package/src/core/adapters/flat.ts +168 -0
  28. package/src/core/aggregate-performance.test.ts +208 -0
  29. package/src/core/aggregate-performance.ts +200 -0
  30. package/src/core/detect-rework.test.ts +134 -0
  31. package/src/core/detect-rework.ts +100 -0
  32. package/src/core/discover-graph.test.ts +378 -0
  33. package/src/core/discover-graph.ts +202 -0
  34. package/src/core/duration-stats.test.ts +116 -0
  35. package/src/core/duration-stats.ts +162 -0
  36. package/src/core/event-log.test.ts +224 -0
  37. package/src/core/event-log.ts +244 -0
  38. package/src/core/extract-variants.test.ts +126 -0
  39. package/src/core/extract-variants.ts +140 -0
  40. package/src/core/filter-log.test.ts +193 -0
  41. package/src/core/filter-log.ts +215 -0
  42. package/src/core/fixtures/generate-bpi-2012-subset.test.ts +50 -0
  43. package/src/core/fixtures/generate-bpi-2012-subset.ts +216 -0
  44. package/src/core/fixtures/generate-bpi-2012-subset.write.ts +40 -0
  45. package/src/core/fixtures/order-to-cash-small.json +200 -0
  46. package/src/core/fixtures/synthetic-log.test.ts +109 -0
  47. package/src/core/fixtures/synthetic-log.ts +167 -0
  48. package/src/core/index.ts +118 -0
  49. package/src/core/reconcile-graph.test.ts +175 -0
  50. package/src/core/reconcile-graph.ts +107 -0
  51. package/src/core/scale.test.ts +80 -0
  52. package/src/core/scale.ts +100 -0
  53. package/src/core/types.ts +151 -0
  54. package/src/core/worker/create-process-worker.test.ts +255 -0
  55. package/src/core/worker/create-process-worker.ts +211 -0
  56. package/src/core/worker/process-worker.ts +80 -0
  57. package/src/index.ts +29 -0
  58. package/src/metric-layer-switch/index.ts +6 -0
  59. package/src/metric-layer-switch/metric-layer-switch.stories.tsx +131 -0
  60. package/src/metric-layer-switch/metric-layer-switch.test.tsx +102 -0
  61. package/src/metric-layer-switch/metric-layer-switch.tsx +276 -0
  62. package/src/process-explorer.stories.tsx +392 -0
  63. package/src/process-kpi-strip/index.ts +6 -0
  64. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +128 -0
  65. package/src/process-kpi-strip/process-kpi-strip.test.tsx +106 -0
  66. package/src/process-kpi-strip/process-kpi-strip.tsx +237 -0
  67. package/src/process-map/index.ts +13 -0
  68. package/src/process-map/map-model.test.ts +326 -0
  69. package/src/process-map/map-model.ts +873 -0
  70. package/src/process-map/process-activity-node.tsx +200 -0
  71. package/src/process-map/process-map-context.ts +71 -0
  72. package/src/process-map/process-map.stories.tsx +673 -0
  73. package/src/process-map/process-map.test.tsx +523 -0
  74. package/src/process-map/process-map.tsx +979 -0
  75. package/src/process-map/process-transition-edge.test.tsx +160 -0
  76. package/src/process-map/process-transition-edge.tsx +151 -0
  77. package/src/process-map/use-process-layout.test.tsx +265 -0
  78. package/src/process-map/use-process-layout.ts +315 -0
  79. package/src/test/contract.test.ts +99 -0
  80. package/src/test/contract.ts +118 -0
  81. package/src/test/doubles.test.tsx +51 -0
  82. package/src/test/doubles.tsx +82 -0
  83. package/src/test/index.ts +34 -0
  84. package/src/test/primitives.tsx +35 -0
  85. package/src/use-process-explorer/index.ts +8 -0
  86. package/src/use-process-explorer/use-process-explorer.test.ts +564 -0
  87. package/src/use-process-explorer/use-process-explorer.ts +540 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Seeded synthetic event-log generator — RM-049.
3
+ *
4
+ * Produces an order-to-cash shaped log — a happy path, two exception branches, a rework
5
+ * loop back to an earlier activity, an occasional repeat of one step, and a step-order
6
+ * swap — at any size, from a seed. That is what lets tests, stories and benchmarks work
7
+ * with a realistically messy log without this repo ever shipping a large binary fixture,
8
+ * and without any of them depending on a wall clock.
9
+ *
10
+ * Determinism is the whole contract: `generateSyntheticLog({ cases: n, seed: s })` returns
11
+ * a byte-identical log on every run, in every engine, forever. Downstream items snapshot
12
+ * against it.
13
+ */
14
+ import type { EventLog, EventRow } from "../types";
15
+
16
+ /** Epoch ms the first case starts at: 2026-01-05T08:00:00.000Z, a Monday morning. */
17
+ export const SYNTHETIC_LOG_EPOCH = 1767600000000;
18
+
19
+ const MINUTE = 60_000;
20
+
21
+ /** Options for {@link generateSyntheticLog}. */
22
+ export interface SyntheticLogOptions {
23
+ /** How many cases to generate. Values below 1 yield an empty log. */
24
+ cases: number;
25
+ /** PRNG seed. The same seed always produces the same log. */
26
+ seed?: number;
27
+ /** Epoch ms the first case starts at. Defaults to {@link SYNTHETIC_LOG_EPOCH}. */
28
+ startTime?: number;
29
+ }
30
+
31
+ /**
32
+ * A deterministic 32-bit PRNG (mulberry32).
33
+ *
34
+ * Deliberately its own copy rather than one shared with the core's duration sampler: this
35
+ * generator's output is a FIXTURE that tests assert exact numbers against, so it must not
36
+ * shift because an unrelated internal in `duration-stats.ts` was retuned.
37
+ */
38
+ function mulberry32(seed: number): () => number {
39
+ let a = seed >>> 0;
40
+ return () => {
41
+ a = (a + 0x6d2b79f5) >>> 0;
42
+ let t = a;
43
+ t = Math.imul(t ^ (t >>> 15), t | 1);
44
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
45
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
46
+ };
47
+ }
48
+
49
+ const RESOURCES = [
50
+ "A. Novak",
51
+ "B. Ferreira",
52
+ "C. Okafor",
53
+ "D. Lindqvist",
54
+ "E. Haddad",
55
+ "Credit Service",
56
+ "Warehouse Robot",
57
+ ] as const;
58
+
59
+ const REGIONS = ["North", "South", "East", "West"] as const;
60
+ const SEGMENTS = ["Enterprise", "Mid-market", "SMB"] as const;
61
+
62
+ /** Activity vocabulary. Exported so a story can label or colour by name without guessing. */
63
+ export const SYNTHETIC_ACTIVITIES = [
64
+ "Create Order",
65
+ "Check Credit",
66
+ "Amend Order",
67
+ "Approve Order",
68
+ "Reject Order",
69
+ "Reserve Stock",
70
+ "Cancel Order",
71
+ "Pick Items",
72
+ "Ship Order",
73
+ "Send Invoice",
74
+ "Receive Payment",
75
+ ] as const;
76
+
77
+ /** One activity name from {@link SYNTHETIC_ACTIVITIES}. */
78
+ export type SyntheticActivity = (typeof SYNTHETIC_ACTIVITIES)[number];
79
+
80
+ /** Build the activity sequence of a single case. Pure given `random`. */
81
+ function buildTrace(random: () => number): SyntheticActivity[] {
82
+ const trace: SyntheticActivity[] = ["Create Order", "Check Credit"];
83
+
84
+ // Rework: an amendment sends the order back through the credit check, at most twice.
85
+ let reworks = 0;
86
+ while (reworks < 2 && random() < 0.14) {
87
+ trace.push("Amend Order", "Check Credit");
88
+ reworks += 1;
89
+ }
90
+
91
+ if (random() < 0.09) {
92
+ trace.push("Reject Order");
93
+ return trace;
94
+ }
95
+
96
+ trace.push("Approve Order", "Reserve Stock");
97
+
98
+ if (random() < 0.05) {
99
+ trace.push("Cancel Order");
100
+ return trace;
101
+ }
102
+
103
+ trace.push("Pick Items");
104
+ // A short-picked order is picked again — the log's only self-loop.
105
+ if (random() < 0.07) trace.push("Pick Items");
106
+
107
+ // Roughly a third of orders are invoiced before they ship.
108
+ if (random() < 0.32) trace.push("Send Invoice", "Ship Order");
109
+ else trace.push("Ship Order", "Send Invoice");
110
+
111
+ trace.push("Receive Payment");
112
+ return trace;
113
+ }
114
+
115
+ /**
116
+ * Generate a synthetic order-to-cash log.
117
+ *
118
+ * Every event is an INTERVAL event: it carries both a `startTimestamp` and a `timestamp`,
119
+ * so activity durations and edge idle times are both non-zero and a performance view has
120
+ * something real to show. Timestamps are epoch numbers rather than ISO strings — a
121
+ * 13 000-case log is a quarter of a million rows, and making a benchmark of the discovery
122
+ * pass mostly measure `Date.parse` would be measuring the wrong thing.
123
+ *
124
+ * Cases are emitted in order, one per `caseId` of the form `case-00001`, zero-padded so
125
+ * lexical and numeric order agree.
126
+ */
127
+ export function generateSyntheticLog(options: SyntheticLogOptions): EventLog {
128
+ const total = Math.max(0, Math.floor(options.cases));
129
+ const random = mulberry32(options.seed ?? 1);
130
+ const epoch = options.startTime ?? SYNTHETIC_LOG_EPOCH;
131
+
132
+ const events: EventRow[] = [];
133
+ const caseAttributes: Record<string, Record<string, unknown>> = {};
134
+ const pad = String(total).length;
135
+
136
+ for (let index = 0; index < total; index += 1) {
137
+ const caseId = `case-${String(index + 1).padStart(Math.max(5, pad), "0")}`;
138
+ const trace = buildTrace(random);
139
+
140
+ caseAttributes[caseId] = {
141
+ region: REGIONS[Math.floor(random() * REGIONS.length)] as string,
142
+ segment: SEGMENTS[Math.floor(random() * SEGMENTS.length)] as string,
143
+ // Whole euros, so a table can render the value without a rounding decision.
144
+ orderValue: 250 + Math.floor(random() * 9750),
145
+ };
146
+
147
+ // Cases arrive about an hour apart, with a jitter that keeps them from lining up.
148
+ let cursor = epoch + index * 61 * MINUTE + Math.floor(random() * 40) * MINUTE;
149
+
150
+ for (const activity of trace) {
151
+ const idle = Math.floor(random() * 180) * MINUTE;
152
+ const work = (2 + Math.floor(random() * 44)) * MINUTE;
153
+ const start = cursor + idle;
154
+ const end = start + work;
155
+ events.push({
156
+ caseId,
157
+ activity,
158
+ timestamp: end,
159
+ startTimestamp: start,
160
+ resource: RESOURCES[Math.floor(random() * RESOURCES.length)] as string,
161
+ });
162
+ cursor = end;
163
+ }
164
+ }
165
+
166
+ return { events, caseAttributes };
167
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `@elabs-ai/components-process/core` — the FRAMEWORK-FREE half of the process package.
3
+ *
4
+ * A gated subpath export (ADR 0006 / ADR 0034): the event-log model, directly-follows
5
+ * derivation, variant grouping and conformance math, with a materially lighter
6
+ * dependency tree than the trunk — no React, no React Flow, no visx. That is what lets
7
+ * a server route, a worker or a unit test import the domain model without pulling a
8
+ * rendering engine.
9
+ *
10
+ * NOTHING in this module may import React or any `@elabs-ai/components-*` package.
11
+ *
12
+ * Wave-1 items APPEND their exports at the end of the block below, each under a
13
+ * `// <Name> — RM-NNN` comment, so concurrent branches merge as appends.
14
+ */
15
+
16
+ // ── Model & derivation (framework-free) ──────────────────────────────────────
17
+
18
+ // Model types — RM-049
19
+ export type {
20
+ ActivityStats,
21
+ DurationStats,
22
+ EventLog,
23
+ EventRow,
24
+ FlowTime,
25
+ FrequencyMode,
26
+ PerformanceAgg,
27
+ ProcessGraph,
28
+ TransitionStats,
29
+ Variant,
30
+ } from "./types";
31
+
32
+ // normalizeLog — RM-049
33
+ export { asNormalizedLog, isNormalizedLog, normalizeLog, toEpochMs } from "./event-log";
34
+ export type { AnyLog, NormalizedCase, NormalizedEvent, NormalizedLog } from "./event-log";
35
+
36
+ // fromFlatRows — RM-049
37
+ export { DEFAULT_LIFECYCLE_VALUES, fromFlatRows, normalizeLifecycle } from "./adapters/flat";
38
+ export type { FlatRow, FlatRowMapping, LifecycleValues } from "./adapters/flat";
39
+
40
+ // fromCsv — RM-049
41
+ export { fromCsv, parseDelimited } from "./adapters/csv";
42
+ export type { CsvMapping, CsvOptions } from "./adapters/csv";
43
+
44
+ // discoverGraph — RM-049
45
+ export { discoverGraph } from "./discover-graph";
46
+ export type { DiscoverGraphOptions } from "./discover-graph";
47
+
48
+ // extractVariants — RM-049
49
+ export { extractVariants, variantId, variantKey, VARIANT_KEY_SEPARATOR } from "./extract-variants";
50
+
51
+ // durationStats — RM-049
52
+ export {
53
+ DURATION_SAMPLE_CAP,
54
+ DurationSampler,
55
+ durationStats,
56
+ EMPTY_DURATION_STATS,
57
+ emptyDurationStats,
58
+ TRIM_FRACTION,
59
+ } from "./duration-stats";
60
+
61
+ // minMax / quantile / clampWidth — RM-049
62
+ export { clampWidth, minMax, quantile, quantileSorted } from "./scale";
63
+
64
+ // generateSyntheticLog — RM-049
65
+ export {
66
+ generateSyntheticLog,
67
+ SYNTHETIC_ACTIVITIES,
68
+ SYNTHETIC_LOG_EPOCH,
69
+ } from "./fixtures/synthetic-log";
70
+ export type { SyntheticActivity, SyntheticLogOptions } from "./fixtures/synthetic-log";
71
+
72
+ // generateBpi2012Subset — RM-053 (#228 F2 — restores the same reachability
73
+ // generateSyntheticLog already has; see generate-bpi-2012-subset.ts's own docblock)
74
+ export {
75
+ BPI_2012_ACTIVITIES,
76
+ BPI_2012_SUBSET_EPOCH,
77
+ generateBpi2012Subset,
78
+ } from "./fixtures/generate-bpi-2012-subset";
79
+ export type { Bpi2012Activity, Bpi2012SubsetOptions } from "./fixtures/generate-bpi-2012-subset";
80
+
81
+ // abstractGraph — RM-050
82
+ export { abstractGraph } from "./abstract-graph";
83
+ export type { AbstractedGraph, AbstractionOptions } from "./abstract-graph";
84
+
85
+ // reconcileGraph — RM-052 round 2 (#227, Invariant F)
86
+ export { reconcileGraph } from "./reconcile-graph";
87
+ export type { ReconciledGraph } from "./reconcile-graph";
88
+
89
+ // aggregatePerformance — RM-050
90
+ export { aggregatePerformance, DURATION_UNIT_MS, performanceValue } from "./aggregate-performance";
91
+ export type {
92
+ DurationUnit,
93
+ PerformanceGraph,
94
+ PerformanceLayer,
95
+ PerformanceOptions,
96
+ } from "./aggregate-performance";
97
+
98
+ // detectRework — RM-050
99
+ export { detectRework } from "./detect-rework";
100
+ export type { ActivityRework, ReworkStats } from "./detect-rework";
101
+
102
+ // filterLog — RM-050
103
+ export { caseMatchesFilters, filterLog, filterNormalizedLog } from "./filter-log";
104
+ export type { FilterSpec } from "./filter-log";
105
+
106
+ // createProcessWorker — RM-050
107
+ export { createProcessWorker } from "./worker/create-process-worker";
108
+ export type {
109
+ CreateProcessWorkerOptions,
110
+ ProcessWorkerHandle,
111
+ ProcessWorkerLike,
112
+ } from "./worker/create-process-worker";
113
+ export { handleProcessRequest } from "./worker/process-worker";
114
+ export type { ProcessWorkerRequest, ProcessWorkerResponse } from "./worker/process-worker";
115
+
116
+ // EDGE_KEY_SEPARATOR — RM-050 (the edge-key convention abstraction and the
117
+ // performance layer both index on; discovery has always used it internally)
118
+ export { EDGE_KEY_SEPARATOR } from "./discover-graph";
@@ -0,0 +1,175 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { EMPTY_DURATION_STATS } from "./duration-stats";
3
+ import { reconcileGraph } from "./reconcile-graph";
4
+ import type { AbstractedGraph } from "./abstract-graph";
5
+ import type { ActivityStats, ProcessGraph, TransitionStats } from "./types";
6
+
7
+ const DURATION = {
8
+ min: 1,
9
+ max: 10,
10
+ mean: 5,
11
+ median: 5,
12
+ p90: 9,
13
+ sum: 50,
14
+ trimmedMean: 5,
15
+ };
16
+
17
+ function activity(id: string, overrides: Partial<ActivityStats> = {}): ActivityStats {
18
+ return {
19
+ id,
20
+ label: id,
21
+ instances: 10,
22
+ cases: 5,
23
+ isStart: false,
24
+ isEnd: false,
25
+ duration: DURATION,
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ function transition(
31
+ source: string,
32
+ target: string,
33
+ overrides: Partial<TransitionStats> = {},
34
+ ): TransitionStats {
35
+ return {
36
+ source,
37
+ target,
38
+ count: 10,
39
+ caseCount: 5,
40
+ duration: DURATION,
41
+ isSelfLoop: source === target,
42
+ isBackEdge: false,
43
+ ...overrides,
44
+ };
45
+ }
46
+
47
+ function graph(overrides: Partial<ProcessGraph> = {}): ProcessGraph {
48
+ return {
49
+ activities: [],
50
+ transitions: [],
51
+ startActivities: {},
52
+ endActivities: {},
53
+ totals: { cases: 0, events: 0, variants: 0 },
54
+ ...overrides,
55
+ };
56
+ }
57
+
58
+ describe("reconcileGraph", () => {
59
+ it("is the identity when filtered is deep-equal to presented (no filter applied)", () => {
60
+ const presented = graph({
61
+ activities: [activity("A", { isStart: true }), activity("B", { isEnd: true })],
62
+ transitions: [transition("A", "B")],
63
+ startActivities: { A: 5 },
64
+ endActivities: { B: 5 },
65
+ totals: { cases: 5, events: 10, variants: 1 },
66
+ });
67
+ // A structurally distinct but deep-equal graph, as a fresh discoverGraph(log) call with
68
+ // no active filter would actually produce.
69
+ const filtered = graph({
70
+ activities: [activity("A", { isStart: true }), activity("B", { isEnd: true })],
71
+ transitions: [transition("A", "B")],
72
+ startActivities: { A: 5 },
73
+ endActivities: { B: 5 },
74
+ totals: { cases: 5, events: 10, variants: 1 },
75
+ });
76
+
77
+ const result = reconcileGraph(presented, filtered);
78
+
79
+ expect(result.graph).toEqual(presented);
80
+ expect(result.excludedActivities).toEqual([]);
81
+ expect(result.excludedTransitions).toEqual([]);
82
+ });
83
+
84
+ it("ghosts an activity the filter dropped: same id, every count zeroed", () => {
85
+ const presented = graph({
86
+ activities: [
87
+ activity("A", { isStart: true, instances: 10, cases: 5 }),
88
+ activity("B", { instances: 3, cases: 2 }),
89
+ ],
90
+ transitions: [transition("A", "B", { count: 3, caseCount: 2 })],
91
+ totals: { cases: 5, events: 13, variants: 2 },
92
+ });
93
+ // The filter kept only "A" — "B" (and its edge) did not survive.
94
+ const filtered = graph({
95
+ activities: [activity("A", { isStart: true, instances: 10, cases: 5 })],
96
+ transitions: [],
97
+ startActivities: { A: 5 },
98
+ totals: { cases: 5, events: 10, variants: 1 },
99
+ });
100
+
101
+ const {
102
+ graph: reconciled,
103
+ excludedActivities,
104
+ excludedTransitions,
105
+ } = reconcileGraph(presented, filtered);
106
+
107
+ // Invariant F: the element set is unchanged — "B" is still on the map.
108
+ expect(reconciled.activities.map((a) => a.id)).toEqual(["A", "B"]);
109
+ expect(reconciled.transitions).toHaveLength(1);
110
+
111
+ const ghost = reconciled.activities.find((a) => a.id === "B")!;
112
+ expect(ghost.instances).toBe(0);
113
+ expect(ghost.cases).toBe(0);
114
+ expect(ghost.isStart).toBe(false);
115
+ expect(ghost.isEnd).toBe(false);
116
+ expect(ghost.duration).toEqual(EMPTY_DURATION_STATS);
117
+
118
+ const ghostEdge = reconciled.transitions[0]!;
119
+ expect(ghostEdge.source).toBe("A");
120
+ expect(ghostEdge.target).toBe("B");
121
+ expect(ghostEdge.count).toBe(0);
122
+ expect(ghostEdge.caseCount).toBe(0);
123
+ expect(ghostEdge.duration).toEqual(EMPTY_DURATION_STATS);
124
+
125
+ expect(excludedActivities).toEqual(["B"]);
126
+ expect(excludedTransitions).toEqual([`AB`]);
127
+ });
128
+
129
+ it("takes startActivities, endActivities and totals from the FILTERED graph", () => {
130
+ const presented = graph({
131
+ activities: [activity("A"), activity("B")],
132
+ startActivities: { A: 5, B: 1 },
133
+ endActivities: { B: 5, A: 1 },
134
+ totals: { cases: 5, events: 20, variants: 3 },
135
+ });
136
+ const filtered = graph({
137
+ activities: [activity("A")],
138
+ startActivities: { A: 2 },
139
+ endActivities: { A: 2 },
140
+ totals: { cases: 2, events: 4, variants: 1 },
141
+ });
142
+
143
+ const { graph: reconciled } = reconcileGraph(presented, filtered);
144
+
145
+ expect(reconciled.startActivities).toEqual({ A: 2 });
146
+ expect(reconciled.endActivities).toEqual({ A: 2 });
147
+ expect(reconciled.totals).toEqual({ cases: 2, events: 4, variants: 1 });
148
+ });
149
+
150
+ it("preserves a surviving element's structural fields (isSelfLoop / isBackEdge) even when zeroed", () => {
151
+ // A ghosted transition still carries its structural shape — only its statistics zero.
152
+ const presented = graph({
153
+ activities: [activity("A")],
154
+ transitions: [transition("A", "A", { isBackEdge: true, count: 4, caseCount: 4 })],
155
+ });
156
+ const filtered = graph({ activities: [activity("A")], transitions: [] });
157
+
158
+ const { graph: reconciled } = reconcileGraph(presented, filtered);
159
+ const ghostEdge = reconciled.transitions[0]!;
160
+ expect(ghostEdge.isSelfLoop).toBe(true);
161
+ expect(ghostEdge.isBackEdge).toBe(true);
162
+ expect(ghostEdge.count).toBe(0);
163
+ });
164
+
165
+ it("passes an extra field on G (AbstractedGraph.hidden) through untouched", () => {
166
+ const presented: AbstractedGraph = {
167
+ ...graph({ activities: [activity("A"), activity("B")] }),
168
+ hidden: { activities: 3, paths: 1 },
169
+ };
170
+ const filtered = graph({ activities: [activity("A")] });
171
+
172
+ const { graph: reconciled } = reconcileGraph(presented, filtered);
173
+ expect(reconciled.hidden).toEqual({ activities: 3, paths: 1 });
174
+ });
175
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * reconcileGraph — merge a FILTERED graph's statistics onto a PRESENTED graph's element set
3
+ * (RM-052 round 2, issue #227, Invariant F).
4
+ *
5
+ * The maintainer's ruling on filtering: keep every process-map step visible, dim the ones a
6
+ * filter excludes — never remove them. Invariant F makes that ruling mechanical: the set of
7
+ * activities and transitions a process map renders is a function of the log and the
8
+ * abstraction alone, never of the active filter intents. Applying or clearing a filter can
9
+ * only change an element's STATE and the statistics it carries; it can never add or remove a
10
+ * node or an edge.
11
+ *
12
+ * `reconcileGraph` is the pure operation that keeps that promise: given the graph a map is
13
+ * about to PRESENT (`presented` — the full, abstracted graph, computed with no filter
14
+ * applied) and the graph the ACTIVE filter actually produced (`filtered`), it returns a graph
15
+ * with `presented`'s exact element set, where every element also present in `filtered` reads
16
+ * `filtered`'s statistics, and every element `filtered` dropped becomes a "ghost" — the same
17
+ * id, at zero. A ghost's statistics are the FILTERED ones (zero), not the full-log ones —
18
+ * painting full-log numbers on an excluded element would make filtering cosmetic.
19
+ *
20
+ * `startActivities`, `endActivities` and `totals` come from `filtered` outright (they are
21
+ * graph-level facts about what actually survived the filter, not per-element stats to
22
+ * reconcile). Extra fields the presented graph carries beyond `ProcessGraph` itself (e.g.
23
+ * `AbstractedGraph.hidden`) pass through untouched — `reconcileGraph` never removes what it
24
+ * did not add.
25
+ *
26
+ * Pipeline ordering is load-bearing: abstraction runs on the FULL graph, BEFORE
27
+ * reconciliation with the filtered graph. Reconciling first (running abstraction on an
28
+ * already-filtered graph) would let the zeroed ghosts get treated as least-frequent and
29
+ * deleted by abstraction's own ranking — reinstating the "filtering removes elements"
30
+ * behaviour the maintainer ruled against, by a second route.
31
+ */
32
+ import { emptyDurationStats } from "./duration-stats";
33
+ import { EDGE_KEY_SEPARATOR } from "./discover-graph";
34
+ import type { ProcessGraph, TransitionStats } from "./types";
35
+
36
+ /** The result of {@link reconcileGraph}. */
37
+ export interface ReconciledGraph<G extends ProcessGraph> {
38
+ /** `presented`'s exact element set, with `filtered`'s statistics where an element survived. */
39
+ graph: G;
40
+ /** Activity ids `presented` carries that `filtered` dropped — rendered as zeroed ghosts. */
41
+ excludedActivities: string[];
42
+ /** Transition keys (`source` + {@link EDGE_KEY_SEPARATOR} + `target`) `filtered` dropped. */
43
+ excludedTransitions: string[];
44
+ }
45
+
46
+ function transitionKey(transition: Pick<TransitionStats, "source" | "target">): string {
47
+ return `${transition.source}${EDGE_KEY_SEPARATOR}${transition.target}`;
48
+ }
49
+
50
+ /**
51
+ * Reconcile a presented graph's element set against what a filter actually produced.
52
+ *
53
+ * `G` is generic over `ProcessGraph` so an already-abstracted graph (`AbstractedGraph`, which
54
+ * adds `hidden`) reconciles without losing its extra field — the return type is `G`, and the
55
+ * implementation spreads `presented` before overwriting only the fields this function owns.
56
+ */
57
+ export function reconcileGraph<G extends ProcessGraph>(
58
+ presented: G,
59
+ filtered: ProcessGraph,
60
+ ): ReconciledGraph<G> {
61
+ const filteredActivities = new Map(
62
+ filtered.activities.map((activity) => [activity.id, activity]),
63
+ );
64
+ const filteredTransitions = new Map(
65
+ filtered.transitions.map((transition) => [transitionKey(transition), transition]),
66
+ );
67
+
68
+ const excludedActivities: string[] = [];
69
+ const activities = presented.activities.map((activity) => {
70
+ const survivor = filteredActivities.get(activity.id);
71
+ if (survivor) return survivor;
72
+ excludedActivities.push(activity.id);
73
+ return {
74
+ ...activity,
75
+ instances: 0,
76
+ cases: 0,
77
+ isStart: false,
78
+ isEnd: false,
79
+ duration: emptyDurationStats(),
80
+ };
81
+ });
82
+
83
+ const excludedTransitions: string[] = [];
84
+ const transitions = presented.transitions.map((transition) => {
85
+ const key = transitionKey(transition);
86
+ const survivor = filteredTransitions.get(key);
87
+ if (survivor) return survivor;
88
+ excludedTransitions.push(key);
89
+ return {
90
+ ...transition,
91
+ count: 0,
92
+ caseCount: 0,
93
+ duration: emptyDurationStats(),
94
+ };
95
+ });
96
+
97
+ const graph: G = {
98
+ ...presented,
99
+ activities,
100
+ transitions,
101
+ startActivities: filtered.startActivities,
102
+ endActivities: filtered.endActivities,
103
+ totals: filtered.totals,
104
+ };
105
+
106
+ return { graph, excludedActivities, excludedTransitions };
107
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { clampWidth, minMax, quantile, quantileSorted } from "./scale";
4
+
5
+ describe("minMax", () => {
6
+ it("returns the extent of the finite values", () => {
7
+ expect(minMax([3, 1, 4, 1, 5])).toEqual([1, 5]);
8
+ });
9
+
10
+ it("ignores non-finite entries rather than propagating them", () => {
11
+ expect(minMax([Number.NaN, 2, Number.POSITIVE_INFINITY, 8])).toEqual([2, 8]);
12
+ });
13
+
14
+ it("answers [0, 0] when there is nothing finite to measure", () => {
15
+ expect(minMax([])).toEqual([0, 0]);
16
+ expect(minMax([Number.NaN])).toEqual([0, 0]);
17
+ });
18
+
19
+ it("handles a single value and negative values", () => {
20
+ expect(minMax([7])).toEqual([7, 7]);
21
+ expect(minMax([-5, -1, -9])).toEqual([-9, -1]);
22
+ });
23
+ });
24
+
25
+ describe("quantile", () => {
26
+ it("interpolates linearly between order statistics (R-7)", () => {
27
+ // pos = 0.9 * (5 - 1) = 3.6 → 40 + 0.6 * (50 - 40) = 46
28
+ expect(quantile([10, 20, 30, 40, 50], 0.9)).toBe(46);
29
+ });
30
+
31
+ it("returns the median at q = 0.5 for odd and even lengths", () => {
32
+ expect(quantile([1, 2, 3], 0.5)).toBe(2);
33
+ expect(quantile([1, 2, 3, 4], 0.5)).toBe(2.5);
34
+ });
35
+
36
+ it("sorts a copy, leaving the caller's array untouched", () => {
37
+ const values = [5, 1, 3];
38
+ expect(quantile(values, 0)).toBe(1);
39
+ expect(values).toEqual([5, 1, 3]);
40
+ });
41
+
42
+ it("clamps q to [0, 1] and answers 0 for an empty input", () => {
43
+ expect(quantile([1, 2, 3], -1)).toBe(1);
44
+ expect(quantile([1, 2, 3], 4)).toBe(3);
45
+ expect(quantile([], 0.5)).toBe(0);
46
+ });
47
+
48
+ it("quantileSorted trusts the caller's ordering", () => {
49
+ expect(quantileSorted([1, 2, 3, 4, 5], 0.25)).toBe(2);
50
+ expect(quantileSorted([42], 0.9)).toBe(42);
51
+ });
52
+ });
53
+
54
+ describe("clampWidth", () => {
55
+ it("maps the domain onto the range linearly", () => {
56
+ expect(clampWidth(0, [0, 10], [1, 5])).toBe(1);
57
+ expect(clampWidth(5, [0, 10], [1, 5])).toBe(3);
58
+ expect(clampWidth(10, [0, 10], [1, 5])).toBe(5);
59
+ });
60
+
61
+ it("clamps values outside the domain to the range endpoints", () => {
62
+ expect(clampWidth(-100, [0, 10], [1, 5])).toBe(1);
63
+ expect(clampWidth(100, [0, 10], [1, 5])).toBe(5);
64
+ });
65
+
66
+ it("answers the range floor for a degenerate domain", () => {
67
+ // Every edge in a graph carrying the same count: the domain has zero width, so
68
+ // there is no information to map and the floor is the documented answer.
69
+ expect(clampWidth(7, [7, 7], [1, 5])).toBe(1);
70
+ });
71
+
72
+ it("honours a descending domain by inverting the mapping", () => {
73
+ expect(clampWidth(0, [10, 0], [1, 5])).toBe(5);
74
+ expect(clampWidth(10, [10, 0], [1, 5])).toBe(1);
75
+ });
76
+
77
+ it("answers the range floor for a non-finite value", () => {
78
+ expect(clampWidth(Number.NaN, [0, 10], [2, 6])).toBe(2);
79
+ });
80
+ });