@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,126 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { discoverGraph } from "./discover-graph";
4
+ import { extractVariants, variantId, variantKey, VARIANT_KEY_SEPARATOR } from "./extract-variants";
5
+ import { generateSyntheticLog } from "./fixtures/synthetic-log";
6
+ import fixture from "./fixtures/order-to-cash-small.json";
7
+ import type { EventLog } from "./types";
8
+
9
+ const orderToCash = fixture as EventLog;
10
+
11
+ describe("variantKey / variantId", () => {
12
+ it("joins on a C0 control character no activity name can contain", () => {
13
+ expect(VARIANT_KEY_SEPARATOR).toHaveLength(1);
14
+ expect(VARIANT_KEY_SEPARATOR.charCodeAt(0)).toBe(1);
15
+ expect(variantKey(["A", "B"]).split(VARIANT_KEY_SEPARATOR)).toEqual(["A", "B"]);
16
+ });
17
+
18
+ it("does not collide for sequences a printable separator would merge", () => {
19
+ // "A, B" as one activity vs "A" then "B" — a comma separator would key both "A, B".
20
+ expect(variantId(["A, B"])).not.toBe(variantId(["A", " B"]));
21
+ });
22
+
23
+ it("is stable across calls and sensitive to order", () => {
24
+ expect(variantId(["A", "B", "C"])).toBe(variantId(["A", "B", "C"]));
25
+ expect(variantId(["A", "B", "C"])).not.toBe(variantId(["C", "B", "A"]));
26
+ });
27
+ });
28
+
29
+ describe("extractVariants on the 5-case order-to-cash fixture", () => {
30
+ const variants = extractVariants(orderToCash);
31
+
32
+ it("groups the five cases into the four hand-counted variants", () => {
33
+ expect(variants.map((v) => [v.count, v.caseIds])).toEqual([
34
+ [2, ["case-1", "case-2"]],
35
+ [1, ["case-4"]],
36
+ [1, ["case-5"]],
37
+ [1, ["case-3"]],
38
+ ]);
39
+ expect(variants[0]?.sequence).toEqual([
40
+ "Create Order",
41
+ "Check Credit",
42
+ "Approve Order",
43
+ "Ship Order",
44
+ "Send Invoice",
45
+ "Receive Payment",
46
+ ]);
47
+ });
48
+
49
+ it("agrees with discoverGraph on the variant count", () => {
50
+ expect(variants).toHaveLength(discoverGraph(orderToCash).totals.variants);
51
+ });
52
+
53
+ it("sums share to 1", () => {
54
+ const total = variants.reduce((sum, variant) => sum + variant.share, 0);
55
+ expect(total).toBeCloseTo(1, 10);
56
+ expect(variants.map((v) => v.share)).toEqual([0.4, 0.2, 0.2, 0.2]);
57
+ });
58
+
59
+ it("keeps cumulativeShare monotonically non-decreasing, ending exactly at 1", () => {
60
+ let previous = 0;
61
+ for (const variant of variants) {
62
+ expect(variant.cumulativeShare).toBeGreaterThanOrEqual(previous);
63
+ previous = variant.cumulativeShare;
64
+ }
65
+ expect(variants.map((v) => v.cumulativeShare)).toEqual([0.4, 0.6, 0.8, 1]);
66
+ expect(variants[variants.length - 1]?.cumulativeShare).toBe(1);
67
+ });
68
+
69
+ it("summarizes END-TO-END case durations, not step durations", () => {
70
+ // case-1 and case-2 each run 09:00 to 14:00 — five hours.
71
+ expect(variants[0]?.duration).toEqual({
72
+ min: 18_000_000,
73
+ max: 18_000_000,
74
+ mean: 18_000_000,
75
+ median: 18_000_000,
76
+ p90: 18_000_000,
77
+ sum: 36_000_000,
78
+ trimmedMean: 18_000_000,
79
+ });
80
+ // case-3 is rejected after two hours; case-5 runs ten.
81
+ expect(variants[3]?.duration.sum).toBe(7_200_000);
82
+ expect(variants[2]?.duration.sum).toBe(36_000_000);
83
+ });
84
+
85
+ it("gives every variant a distinct, reproducible id", () => {
86
+ const ids = variants.map((v) => v.id);
87
+ expect(new Set(ids).size).toBe(ids.length);
88
+ expect(ids).toEqual(extractVariants(orderToCash).map((v) => v.id));
89
+ expect(variants[0]?.id).toBe(variantId(variants[0]?.sequence ?? []));
90
+ });
91
+ });
92
+
93
+ describe("extractVariants ordering and edge cases", () => {
94
+ it("ranks by count descending and breaks ties by key, so the order is total", () => {
95
+ const log: EventLog = {
96
+ events: [
97
+ { caseId: "c1", activity: "B", timestamp: 0 },
98
+ { caseId: "c2", activity: "A", timestamp: 0 },
99
+ { caseId: "c3", activity: "C", timestamp: 0 },
100
+ { caseId: "c4", activity: "C", timestamp: 1 },
101
+ ],
102
+ };
103
+ expect(extractVariants(log).map((v) => [v.sequence, v.count])).toEqual([
104
+ [["C"], 2],
105
+ [["A"], 1],
106
+ [["B"], 1],
107
+ ]);
108
+ });
109
+
110
+ it("answers an empty log with an empty array, not a zero-count variant", () => {
111
+ expect(extractVariants({ events: [] })).toEqual([]);
112
+ });
113
+
114
+ it("holds the two invariants on a large synthetic log too", () => {
115
+ const variants = extractVariants(generateSyntheticLog({ cases: 500, seed: 7 }));
116
+ expect(variants.length).toBeGreaterThan(1);
117
+ expect(variants.reduce((sum, v) => sum + v.share, 0)).toBeCloseTo(1, 10);
118
+ let previous = 0;
119
+ for (const variant of variants) {
120
+ expect(variant.cumulativeShare).toBeGreaterThanOrEqual(previous);
121
+ previous = variant.cumulativeShare;
122
+ }
123
+ expect(previous).toBe(1);
124
+ expect(variants.reduce((sum, v) => sum + v.count, 0)).toBe(500);
125
+ });
126
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Variant extraction — RM-049.
3
+ *
4
+ * A "variant" is one distinct activity sequence, together with every case that follows
5
+ * it. Grouping traces by their sequence and ranking those groups by frequency is the
6
+ * standard second view onto a log (the first being the directly-follows graph), and the
7
+ * grouping shape below follows pm4js; see `ATTRIBUTION.md`. No pm4js code is copied.
8
+ *
9
+ * Deterministic: ids are hashes of the sequence, ordering is fully tie-broken, and the
10
+ * cumulative share is computed from counts rather than by summing floats, so it is exactly
11
+ * `1` on the last variant instead of `0.9999999999999998`.
12
+ */
13
+ import { durationStats } from "./duration-stats";
14
+ import { asNormalizedLog, type AnyLog, type NormalizedCase } from "./event-log";
15
+ import type { Variant } from "./types";
16
+
17
+ /**
18
+ * Separator joining a sequence into its variant key.
19
+ *
20
+ * U+0001 (START OF HEADING) is a C0 control character. No real activity name contains
21
+ * one, which is what makes joining on it lossless where a printable separator
22
+ * (`" -> "`, `","`) would collide with a name that happens to contain the separator.
23
+ */
24
+ export const VARIANT_KEY_SEPARATOR = "\u0001";
25
+
26
+ /** Join an activity sequence into its canonical variant key. */
27
+ export function variantKey(sequence: readonly string[]): string {
28
+ return sequence.join(VARIANT_KEY_SEPARATOR);
29
+ }
30
+
31
+ /** FNV-1a, 32-bit. Small, fast, dependency-free and identical in every engine. */
32
+ function fnv1a32(text: string, basis: number): number {
33
+ let hash = basis >>> 0;
34
+ for (let i = 0; i < text.length; i += 1) {
35
+ hash ^= text.charCodeAt(i);
36
+ hash = Math.imul(hash, 0x01000193) >>> 0;
37
+ }
38
+ return hash >>> 0;
39
+ }
40
+
41
+ function hex8(value: number): string {
42
+ return (value >>> 0).toString(16).padStart(8, "0");
43
+ }
44
+
45
+ /**
46
+ * The stable id for an activity sequence: `v` followed by 64 bits of FNV-1a over the
47
+ * variant key, as hex.
48
+ *
49
+ * Exported because a selection is a wave-wide concern — RM-052's variant explorer and
50
+ * RM-053's case table both need to name the same variant without holding the object, and
51
+ * a hash of the sequence is the only identifier that survives a re-run, a worker boundary
52
+ * and a URL.
53
+ */
54
+ export function variantId(sequence: readonly string[]): string {
55
+ const key = variantKey(sequence);
56
+ return `v${hex8(fnv1a32(key, 0x811c9dc5))}${hex8(fnv1a32(key, 0x84222325))}`;
57
+ }
58
+
59
+ interface VariantAccumulator {
60
+ key: string;
61
+ sequence: string[];
62
+ caseIds: string[];
63
+ durations: number[];
64
+ }
65
+
66
+ /**
67
+ * Group `log`'s cases into variants, ranked by frequency.
68
+ *
69
+ * Ordering is `count` descending, ties broken by the variant key ascending — a total
70
+ * order, so the same log always produces the same array in the same positions.
71
+ *
72
+ * `share` is `count / cases`. `cumulativeShare` is the running `cumulativeCount / cases`,
73
+ * which is monotonically non-decreasing by construction and exactly `1` on the last
74
+ * entry. `duration` summarizes the END-TO-END case durations of the variant's cases, so
75
+ * it answers "how long does this path take", not "how long does one step take".
76
+ *
77
+ * An empty log yields an empty array — never a single zero-count variant.
78
+ */
79
+ export function extractVariants(log: AnyLog): Variant[] {
80
+ const normalized = asNormalizedLog(log);
81
+ const totalCases = normalized.cases.length;
82
+ if (totalCases === 0) return [];
83
+
84
+ const groups = new Map<string, VariantAccumulator>();
85
+ for (const kase of normalized.cases) {
86
+ const sequence = sequenceOf(kase);
87
+ const key = variantKey(sequence);
88
+ let group = groups.get(key);
89
+ if (group === undefined) {
90
+ group = { key, sequence, caseIds: [], durations: [] };
91
+ groups.set(key, group);
92
+ }
93
+ group.caseIds.push(kase.caseId);
94
+ group.durations.push(kase.duration);
95
+ }
96
+
97
+ const ranked = [...groups.values()].sort(
98
+ (a, b) => b.caseIds.length - a.caseIds.length || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0),
99
+ );
100
+
101
+ const taken = new Set<string>();
102
+ const variants: Variant[] = [];
103
+ let cumulativeCount = 0;
104
+
105
+ for (const group of ranked) {
106
+ const count = group.caseIds.length;
107
+ cumulativeCount += count;
108
+ // A 64-bit hash collision between two different sequences is vanishingly unlikely,
109
+ // but "vanishingly unlikely" is not "impossible" and a duplicate id would silently
110
+ // merge two rows in a table. Disambiguate deterministically: `ranked` is totally
111
+ // ordered, so the suffix an id receives is the same on every run.
112
+ let id = variantId(group.sequence);
113
+ if (taken.has(id)) {
114
+ let suffix = 1;
115
+ while (taken.has(`${id}-${suffix}`)) suffix += 1;
116
+ id = `${id}-${suffix}`;
117
+ }
118
+ taken.add(id);
119
+
120
+ variants.push({
121
+ id,
122
+ sequence: group.sequence,
123
+ count,
124
+ share: count / totalCases,
125
+ cumulativeShare: cumulativeCount / totalCases,
126
+ caseIds: group.caseIds,
127
+ duration: durationStats(group.durations),
128
+ });
129
+ }
130
+
131
+ return variants;
132
+ }
133
+
134
+ function sequenceOf(kase: NormalizedCase): string[] {
135
+ const sequence = new Array<string>(kase.events.length);
136
+ for (let i = 0; i < kase.events.length; i += 1) {
137
+ sequence[i] = (kase.events[i] as { activity: string }).activity;
138
+ }
139
+ return sequence;
140
+ }
@@ -0,0 +1,193 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { discoverGraph } from "./discover-graph";
4
+ import { normalizeLog } from "./event-log";
5
+ import { extractVariants, variantId } from "./extract-variants";
6
+ import { caseMatchesFilters, filterLog, filterNormalizedLog, type FilterSpec } from "./filter-log";
7
+ import fixture from "./fixtures/order-to-cash-small.json";
8
+ import type { EventLog } from "./types";
9
+
10
+ const orderToCash = fixture as EventLog;
11
+
12
+ /**
13
+ * The five traces in `order-to-cash-small.json` (see `discover-graph.test.ts` for the
14
+ * full listing). What matters here:
15
+ *
16
+ * case-1, case-2 the happy path, ending in Receive Payment
17
+ * case-3 Create Order · Check Credit · Reject Order
18
+ * case-4 the happy path with an Amend Order detour and a second Check Credit
19
+ * case-5 the happy path with Send Invoice and Ship Order swapped
20
+ */
21
+ const survivors = (log: EventLog, specs: FilterSpec[]): string[] => [
22
+ ...new Set(filterLog(log, specs).events.map((row) => row.caseId)),
23
+ ];
24
+
25
+ describe("filterLog case predicates", () => {
26
+ it("keeps or drops on activity presence", () => {
27
+ expect(survivors(orderToCash, [{ kind: "with", activity: "Reject Order" }])).toEqual([
28
+ "case-3",
29
+ ]);
30
+ expect(survivors(orderToCash, [{ kind: "without", activity: "Reject Order" }])).toEqual([
31
+ "case-1",
32
+ "case-2",
33
+ "case-4",
34
+ "case-5",
35
+ ]);
36
+ });
37
+
38
+ it("matches on the first and last activity of a trace", () => {
39
+ expect(survivors(orderToCash, [{ kind: "startsWith", activity: "Create Order" }])).toHaveLength(
40
+ 5,
41
+ );
42
+ expect(survivors(orderToCash, [{ kind: "endsWith", activity: "Reject Order" }])).toEqual([
43
+ "case-3",
44
+ ]);
45
+ });
46
+
47
+ it("separates a direct follower from an eventual one", () => {
48
+ // Ship Order is directly followed by Send Invoice in cases 1, 2 and 4; in case-5 the
49
+ // two are swapped, so Send Invoice still EVENTUALLY follows nothing of the sort —
50
+ // there Ship Order comes second and Receive Payment follows it.
51
+ expect(
52
+ survivors(orderToCash, [
53
+ { kind: "follower", a: "Ship Order", b: "Send Invoice", direct: true },
54
+ ]),
55
+ ).toEqual(["case-1", "case-2", "case-4"]);
56
+ expect(
57
+ survivors(orderToCash, [{ kind: "follower", a: "Create Order", b: "Receive Payment" }]),
58
+ ).toEqual(["case-1", "case-2", "case-4", "case-5"]);
59
+ // Eventually-follows is not symmetric, and does not fire on the same occurrence twice.
60
+ expect(
61
+ survivors(orderToCash, [{ kind: "follower", a: "Receive Payment", b: "Create Order" }]),
62
+ ).toEqual([]);
63
+ });
64
+
65
+ it("filters on the case's throughput time, inclusive at both bounds", () => {
66
+ const durations = normalizeLog(orderToCash).cases.map((kase) => kase.duration);
67
+ const shortest = Math.min(...durations);
68
+ expect(survivors(orderToCash, [{ kind: "duration", max: shortest }])).toEqual(["case-3"]);
69
+ expect(survivors(orderToCash, [{ kind: "duration", min: shortest }])).toHaveLength(5);
70
+ expect(survivors(orderToCash, [{ kind: "duration" }])).toHaveLength(5);
71
+ });
72
+
73
+ it("selects by variant id and by case id", () => {
74
+ const variants = extractVariants(orderToCash);
75
+ const busiest = variants[0] as { id: string; caseIds: string[] };
76
+ expect(survivors(orderToCash, [{ kind: "variant", ids: [busiest.id] }])).toEqual(
77
+ busiest.caseIds,
78
+ );
79
+ expect(variantId(["Create Order", "Check Credit", "Reject Order"])).toBe(
80
+ (variants.find((variant) => variant.sequence.includes("Reject Order")) as { id: string }).id,
81
+ );
82
+ expect(survivors(orderToCash, [{ kind: "cases", ids: ["case-2", "case-5"] }])).toEqual([
83
+ "case-2",
84
+ "case-5",
85
+ ]);
86
+ });
87
+
88
+ it("ANDs its specs, and keeps everything when given none", () => {
89
+ expect(
90
+ survivors(orderToCash, [
91
+ { kind: "with", activity: "Check Credit" },
92
+ { kind: "without", activity: "Amend Order" },
93
+ { kind: "endsWith", activity: "Receive Payment" },
94
+ ]),
95
+ ).toEqual(["case-1", "case-2", "case-5"]);
96
+ expect(filterLog(orderToCash, [])).toBe(orderToCash);
97
+ });
98
+ });
99
+
100
+ describe("filterLog attribute predicates", () => {
101
+ const withAttributes: EventLog = {
102
+ events: [
103
+ { caseId: "c1", activity: "A", timestamp: 0, resource: "Robot" },
104
+ { caseId: "c1", activity: "B", timestamp: 1000, resource: "Ada" },
105
+ { caseId: "c2", activity: "A", timestamp: 0, resource: "Ada" },
106
+ { caseId: "c3", activity: "A", timestamp: 0, attributes: { amount: 250 } },
107
+ ],
108
+ caseAttributes: {
109
+ c1: { region: "North", priority: 3 },
110
+ c2: { region: "South", priority: 1 },
111
+ },
112
+ };
113
+
114
+ const keep = (spec: FilterSpec): string[] => survivors(withAttributes, [spec]);
115
+
116
+ it("reads a case attribute", () => {
117
+ expect(keep({ kind: "attribute", key: "region", op: "eq", value: "North" })).toEqual(["c1"]);
118
+ expect(keep({ kind: "attribute", key: "priority", op: "gt", value: 2 })).toEqual(["c1"]);
119
+ expect(keep({ kind: "attribute", key: "priority", op: "lt", value: 2 })).toEqual(["c2"]);
120
+ expect(keep({ kind: "attribute", key: "region", op: "in", value: ["North", "South"] })).toEqual(
121
+ ["c1", "c2"],
122
+ );
123
+ });
124
+
125
+ it("falls back to the events — including `resource` — when the case has no such attribute", () => {
126
+ expect(keep({ kind: "attribute", key: "resource", op: "eq", value: "Robot" })).toEqual(["c1"]);
127
+ expect(keep({ kind: "attribute", key: "resource", op: "eq", value: "Ada" })).toEqual([
128
+ "c1",
129
+ "c2",
130
+ ]);
131
+ expect(keep({ kind: "attribute", key: "amount", op: "gt", value: 100 })).toEqual(["c3"]);
132
+ });
133
+
134
+ it("treats `ne` as the negation of `eq`, not as `some value differs`", () => {
135
+ // c1's events carry BOTH Robot and Ada, so an `eq`/`ne` pair on the same value must
136
+ // never both match it — otherwise a filter and its complement would overlap.
137
+ const matched = keep({ kind: "attribute", key: "resource", op: "eq", value: "Ada" });
138
+ const unmatched = keep({ kind: "attribute", key: "resource", op: "ne", value: "Ada" });
139
+ expect(matched).toEqual(["c1", "c2"]);
140
+ expect(unmatched).toEqual(["c3"]);
141
+ expect(matched.filter((id) => unmatched.includes(id))).toEqual([]);
142
+ });
143
+
144
+ it("does not compare across types, and needs an array for `in`", () => {
145
+ expect(keep({ kind: "attribute", key: "priority", op: "gt", value: "2" })).toEqual([]);
146
+ expect(keep({ kind: "attribute", key: "region", op: "in", value: "North" })).toEqual([]);
147
+ });
148
+
149
+ it("narrows caseAttributes to the survivors", () => {
150
+ const filtered = filterLog(withAttributes, [
151
+ { kind: "attribute", key: "region", op: "eq", value: "North" },
152
+ ]);
153
+ expect(filtered.caseAttributes).toEqual({ c1: { region: "North", priority: 3 } });
154
+ });
155
+ });
156
+
157
+ describe("filterLog composition", () => {
158
+ it("keeps every ROW of a surviving case, in input order", () => {
159
+ const filtered = filterLog(orderToCash, [{ kind: "with", activity: "Amend Order" }]);
160
+ expect(filtered.events).toEqual(orderToCash.events.filter((row) => row.caseId === "case-4"));
161
+ });
162
+
163
+ it("feeds discoverGraph and extractVariants unchanged", () => {
164
+ const filtered = filterLog(orderToCash, [{ kind: "without", activity: "Reject Order" }]);
165
+ const graph = discoverGraph(filtered);
166
+ expect(graph.totals.cases).toBe(4);
167
+ expect(graph.activities.map((activity) => activity.id)).not.toContain("Reject Order");
168
+ expect(extractVariants(filtered).map((variant) => variant.count)).toEqual([2, 1, 1]);
169
+ });
170
+
171
+ it("agrees with filterNormalizedLog, which normalizes at most once", () => {
172
+ const specs: FilterSpec[] = [{ kind: "endsWith", activity: "Receive Payment" }];
173
+ const normalized = normalizeLog(orderToCash);
174
+ const viaNormalized = filterNormalizedLog(normalized, specs);
175
+ expect(viaNormalized.cases.map((kase) => kase.caseId)).toEqual(survivors(orderToCash, specs));
176
+ expect(viaNormalized.totals).toEqual({ cases: 4, events: 26 });
177
+ // The surviving cases are the ORIGINAL objects — no re-derivation happened.
178
+ for (const kase of viaNormalized.cases) expect(normalized.cases).toContain(kase);
179
+ });
180
+
181
+ it("returns the input untouched when there is nothing to filter on", () => {
182
+ const normalized = normalizeLog(orderToCash);
183
+ expect(filterNormalizedLog(normalized, [])).toBe(normalized);
184
+ });
185
+
186
+ it("exposes the predicate on its own, for highlighting rather than removing", () => {
187
+ const normalized = normalizeLog(orderToCash);
188
+ const rejected = normalized.cases.filter((kase) =>
189
+ caseMatchesFilters(kase, [{ kind: "with", activity: "Reject Order" }]),
190
+ );
191
+ expect(rejected.map((kase) => kase.caseId)).toEqual(["case-3"]);
192
+ });
193
+ });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Case-level log filtering — RM-050.
3
+ *
4
+ * Every filter in a process-mining tool is a CASE predicate, not an event predicate:
5
+ * "cases that contain Reject Order", "cases that took longer than a day". Removing single
6
+ * events would rewrite traces and invent directly-follows relations that never happened,
7
+ * so a filter here only ever keeps or drops a case whole. That is what makes the result
8
+ * safe to hand straight back to `discoverGraph` / `extractVariants`.
9
+ *
10
+ * Specs AND together — an empty list keeps everything.
11
+ */
12
+ import { asNormalizedLog, type AnyLog, type NormalizedCase, type NormalizedLog } from "./event-log";
13
+ import { variantId } from "./extract-variants";
14
+ import type { EventLog } from "./types";
15
+
16
+ /** One case-level predicate. {@link filterLog} ANDs a list of them. */
17
+ export type FilterSpec =
18
+ /** The case contains `activity` at least once. */
19
+ | { kind: "with"; activity: string }
20
+ /** The case never contains `activity`. */
21
+ | { kind: "without"; activity: string }
22
+ /** The case's FIRST activity is `activity`. */
23
+ | { kind: "startsWith"; activity: string }
24
+ /** The case's LAST activity is `activity`. */
25
+ | { kind: "endsWith"; activity: string }
26
+ /**
27
+ * `b` follows `a` in the case — directly (adjacent) when `direct`, otherwise eventually
28
+ * (anywhere later in the trace).
29
+ */
30
+ | { kind: "follower"; a: string; b: string; direct?: boolean }
31
+ /** An attribute comparison — see {@link caseMatchesFilters} for how `key` is resolved. */
32
+ | { kind: "attribute"; key: string; op: "eq" | "ne" | "gt" | "lt" | "in"; value: unknown }
33
+ /** The case's throughput time, in milliseconds. Both bounds are INCLUSIVE. */
34
+ | { kind: "duration"; min?: number; max?: number }
35
+ /** The case follows one of these variants (see `variantId`). */
36
+ | { kind: "variant"; ids: string[] }
37
+ /** The case is one of these case ids. */
38
+ | { kind: "cases"; ids: string[] };
39
+
40
+ /** The activity sequence of a normalized case. */
41
+ function sequenceOf(kase: NormalizedCase): string[] {
42
+ const sequence = new Array<string>(kase.events.length);
43
+ for (let i = 0; i < kase.events.length; i += 1) {
44
+ sequence[i] = (kase.events[i] as { activity: string }).activity;
45
+ }
46
+ return sequence;
47
+ }
48
+
49
+ /**
50
+ * Every value the case offers for `key`.
51
+ *
52
+ * Case attributes win: when `caseAttributes` defines the key, that single value IS the
53
+ * case's answer. Otherwise the key is looked for on the events — including `resource`,
54
+ * which is a first-class field rather than an entry in `attributes` — and the case offers
55
+ * every distinct value its events carry. So "cases handled by the credit service" is
56
+ * expressible without the caller pre-aggregating anything.
57
+ */
58
+ function valuesFor(kase: NormalizedCase, key: string): unknown[] {
59
+ const attributes = kase.attributes;
60
+ if (attributes !== undefined && Object.hasOwn(attributes, key)) return [attributes[key]];
61
+
62
+ const values: unknown[] = [];
63
+ const seen = new Set<unknown>();
64
+ for (const event of kase.events) {
65
+ const value = key === "resource" ? event.resource : event.attributes?.[key];
66
+ if (value === undefined) continue;
67
+ if (seen.has(value)) continue;
68
+ seen.add(value);
69
+ values.push(value);
70
+ }
71
+ return values;
72
+ }
73
+
74
+ /** `>` / `<` over two numbers or two strings; anything else does not compare. */
75
+ function ordered(left: unknown, right: unknown, wantGreater: boolean): boolean {
76
+ if (typeof left === "number" && typeof right === "number") {
77
+ return wantGreater ? left > right : left < right;
78
+ }
79
+ if (typeof left === "string" && typeof right === "string") {
80
+ return wantGreater ? left > right : left < right;
81
+ }
82
+ return false;
83
+ }
84
+
85
+ function matchesAttribute(
86
+ kase: NormalizedCase,
87
+ spec: Extract<FilterSpec, { kind: "attribute" }>,
88
+ ): boolean {
89
+ const values = valuesFor(kase, spec.key);
90
+ switch (spec.op) {
91
+ case "eq":
92
+ return values.some((value) => Object.is(value, spec.value));
93
+ // `ne` is the negation of `eq`, not "some value differs" — otherwise a case offering
94
+ // two resources would satisfy both `eq` and `ne` against the same value.
95
+ case "ne":
96
+ return !values.some((value) => Object.is(value, spec.value));
97
+ case "gt":
98
+ return values.some((value) => ordered(value, spec.value, true));
99
+ case "lt":
100
+ return values.some((value) => ordered(value, spec.value, false));
101
+ case "in": {
102
+ if (!Array.isArray(spec.value)) return false;
103
+ const allowed = spec.value as unknown[];
104
+ return values.some((value) => allowed.some((candidate) => Object.is(value, candidate)));
105
+ }
106
+ default:
107
+ return false;
108
+ }
109
+ }
110
+
111
+ function matchesOne(kase: NormalizedCase, sequence: readonly string[], spec: FilterSpec): boolean {
112
+ switch (spec.kind) {
113
+ case "with":
114
+ return sequence.includes(spec.activity);
115
+ case "without":
116
+ return !sequence.includes(spec.activity);
117
+ case "startsWith":
118
+ return sequence[0] === spec.activity;
119
+ case "endsWith":
120
+ return sequence[sequence.length - 1] === spec.activity;
121
+ case "follower": {
122
+ if (spec.direct === true) {
123
+ for (let i = 0; i + 1 < sequence.length; i += 1) {
124
+ if (sequence[i] === spec.a && sequence[i + 1] === spec.b) return true;
125
+ }
126
+ return false;
127
+ }
128
+ const first = sequence.indexOf(spec.a);
129
+ if (first < 0) return false;
130
+ return sequence.indexOf(spec.b, first + 1) >= 0;
131
+ }
132
+ case "attribute":
133
+ return matchesAttribute(kase, spec);
134
+ case "duration": {
135
+ if (spec.min === undefined && spec.max === undefined) return true;
136
+ // An unmeasurable case (every timestamp unparseable) cannot satisfy a bound.
137
+ if (!Number.isFinite(kase.duration)) return false;
138
+ if (spec.min !== undefined && kase.duration < spec.min) return false;
139
+ if (spec.max !== undefined && kase.duration > spec.max) return false;
140
+ return true;
141
+ }
142
+ case "variant":
143
+ return spec.ids.includes(variantId(sequence));
144
+ case "cases":
145
+ return spec.ids.includes(kase.caseId);
146
+ default:
147
+ return true;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Does `kase` satisfy every spec?
153
+ *
154
+ * Exported because a case table and a variant list want to HIGHLIGHT what a filter
155
+ * selects as often as they want to remove what it does not.
156
+ */
157
+ export function caseMatchesFilters(kase: NormalizedCase, specs: readonly FilterSpec[]): boolean {
158
+ if (specs.length === 0) return true;
159
+ const sequence = sequenceOf(kase);
160
+ for (const spec of specs) {
161
+ if (!matchesOne(kase, sequence, spec)) return false;
162
+ }
163
+ return true;
164
+ }
165
+
166
+ /**
167
+ * Filter a NORMALIZED log — the composable form.
168
+ *
169
+ * This is the one to reach for in a pipeline: it accepts either shape, normalizes at most
170
+ * once, and returns the normalized result, so
171
+ * `discoverGraph(filterNormalizedLog(normalized, specs))` re-parses nothing. `totals` are
172
+ * recomputed over the surviving cases; case order is preserved.
173
+ */
174
+ export function filterNormalizedLog(log: AnyLog, specs: readonly FilterSpec[]): NormalizedLog {
175
+ const normalized = asNormalizedLog(log);
176
+ if (specs.length === 0) return normalized;
177
+
178
+ const cases: NormalizedCase[] = [];
179
+ let events = 0;
180
+ for (const kase of normalized.cases) {
181
+ if (!caseMatchesFilters(kase, specs)) continue;
182
+ cases.push(kase);
183
+ events += kase.events.length;
184
+ }
185
+ return { cases, totals: { cases: cases.length, events } };
186
+ }
187
+
188
+ /**
189
+ * Filter a raw {@link EventLog}, answering a raw {@link EventLog}.
190
+ *
191
+ * Every ROW of a surviving case is kept, in input order, including rows normalization
192
+ * itself discards (an unpaired lifecycle half, say) — so the answer round-trips: it can be
193
+ * re-exported, handed to a different adapter, or re-normalized with different options.
194
+ * `caseAttributes` is narrowed to the surviving cases.
195
+ *
196
+ * Use {@link filterNormalizedLog} instead when the next step is another `/core` derivation;
197
+ * this form necessarily makes the caller normalize again.
198
+ */
199
+ export function filterLog(log: EventLog, specs: readonly FilterSpec[]): EventLog {
200
+ if (specs.length === 0) return log;
201
+ const kept = new Set<string>();
202
+ for (const kase of filterNormalizedLog(log, specs).cases) kept.add(kase.caseId);
203
+
204
+ const filtered: EventLog = { events: log.events.filter((row) => kept.has(row?.caseId)) };
205
+ if (log.caseAttributes !== undefined) {
206
+ const caseAttributes: Record<string, Record<string, unknown>> = {};
207
+ for (const caseId of Object.keys(log.caseAttributes)) {
208
+ if (kept.has(caseId)) {
209
+ caseAttributes[caseId] = log.caseAttributes[caseId] as Record<string, unknown>;
210
+ }
211
+ }
212
+ filtered.caseAttributes = caseAttributes;
213
+ }
214
+ return filtered;
215
+ }