@elabs-ai/components-process 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +8 -1
  2. package/dist/core/index.d.ts +801 -3
  3. package/dist/core/index.js +1334 -0
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/index.d.ts +1889 -34
  6. package/dist/index.js +5512 -196
  7. package/dist/index.js.map +1 -1
  8. package/dist/test/index.d.ts +223 -5
  9. package/dist/test/index.js +346 -191
  10. package/dist/test/index.js.map +1 -1
  11. package/package.json +14 -13
  12. package/src/__contract__/case-table.contract.test.tsx +49 -0
  13. package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
  14. package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
  15. package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
  16. package/src/__contract__/process-kpi-strip.contract.test.tsx +49 -0
  17. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  18. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  19. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  20. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  21. package/src/case-table/case-table.stories.tsx +89 -0
  22. package/src/case-table/case-table.test.tsx +148 -0
  23. package/src/case-table/case-table.tsx +144 -0
  24. package/src/case-table/columns.ts +116 -0
  25. package/src/case-table/index.ts +11 -0
  26. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  27. package/src/case-timeline/case-timeline-model.ts +112 -0
  28. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  29. package/src/case-timeline/case-timeline.test.tsx +51 -0
  30. package/src/case-timeline/case-timeline.tsx +109 -0
  31. package/src/case-timeline/index.ts +9 -0
  32. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  33. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  34. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  35. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  36. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  37. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  38. package/src/conformance-overlay/conformance-state.ts +220 -0
  39. package/src/conformance-overlay/index.ts +4 -0
  40. package/src/core/activity-color-scale.test.ts +107 -0
  41. package/src/core/activity-color-scale.ts +133 -0
  42. package/src/core/adapters/ocel.test.ts +112 -0
  43. package/src/core/adapters/ocel.ts +359 -0
  44. package/src/core/adapters/xes.test.ts +293 -0
  45. package/src/core/adapters/xes.ts +384 -0
  46. package/src/core/cases-from-log.test.ts +72 -0
  47. package/src/core/cases-from-log.ts +85 -0
  48. package/src/core/conformance.test.ts +80 -0
  49. package/src/core/conformance.ts +91 -0
  50. package/src/core/diff-graphs.test.ts +151 -0
  51. package/src/core/diff-graphs.ts +118 -0
  52. package/src/core/discover-object-centric-graph.test.ts +94 -0
  53. package/src/core/discover-object-centric-graph.ts +296 -0
  54. package/src/core/fixtures/ocel-sample.ts +82 -0
  55. package/src/core/fixtures/sample.xes +68 -0
  56. package/src/core/index.ts +113 -0
  57. package/src/core/reference-model.test.ts +43 -0
  58. package/src/core/reference-model.ts +116 -0
  59. package/src/core/replay-timeline.test.ts +161 -0
  60. package/src/core/replay-timeline.ts +260 -0
  61. package/src/core/segments.test.ts +185 -0
  62. package/src/core/segments.ts +153 -0
  63. package/src/core/token-replay.test.ts +218 -0
  64. package/src/core/token-replay.ts +456 -0
  65. package/src/core/types.ts +2 -2
  66. package/src/dotted-chart/compute-dots.test.ts +176 -0
  67. package/src/dotted-chart/compute-dots.ts +241 -0
  68. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  69. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  70. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  71. package/src/dotted-chart/dotted-chart.tsx +841 -0
  72. package/src/dotted-chart/index.ts +23 -0
  73. package/src/dotted-chart/use-element-size.ts +33 -0
  74. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  75. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  76. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  77. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  78. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  79. package/src/happy-path-editor/index.ts +4 -0
  80. package/src/index.ts +51 -1
  81. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  82. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  83. package/src/performance-spectrum/index.ts +25 -0
  84. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  85. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  86. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  87. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  88. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  89. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  90. package/src/process-compare/compare-model.ts +83 -0
  91. package/src/process-compare/compare-side.tsx +42 -0
  92. package/src/process-compare/diff-to-graph.ts +104 -0
  93. package/src/process-compare/index.ts +23 -0
  94. package/src/process-compare/process-compare.stories.tsx +184 -0
  95. package/src/process-compare/process-compare.test.tsx +224 -0
  96. package/src/process-compare/process-compare.tsx +251 -0
  97. package/src/process-explorer.stories.tsx +1 -1
  98. package/src/process-filter-bar/index.ts +2 -0
  99. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  100. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  101. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  102. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  103. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  104. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  105. package/src/process-map/activity-accent.ts +25 -0
  106. package/src/process-map/index.ts +1 -0
  107. package/src/process-map/map-model.test.ts +16 -0
  108. package/src/process-map/map-model.ts +323 -1
  109. package/src/process-map/object-centric-map.test.tsx +132 -0
  110. package/src/process-map/process-activity-node.tsx +152 -14
  111. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  112. package/src/process-map/process-map.stories.tsx +64 -0
  113. package/src/process-map/process-map.tsx +240 -16
  114. package/src/process-map/process-transition-edge.test.tsx +47 -0
  115. package/src/process-map/process-transition-edge.tsx +134 -7
  116. package/src/process-map/use-process-layout.ts +30 -9
  117. package/src/process-replay/congestion-heat.tsx +107 -0
  118. package/src/process-replay/index.ts +14 -0
  119. package/src/process-replay/process-replay.stories.tsx +168 -0
  120. package/src/process-replay/process-replay.test.tsx +170 -0
  121. package/src/process-replay/process-replay.tsx +285 -0
  122. package/src/process-replay/replay-controls.tsx +147 -0
  123. package/src/process-replay/replay-format.ts +83 -0
  124. package/src/process-replay/replay-tokens-context.ts +30 -0
  125. package/src/process-replay/use-controllable-value.ts +30 -0
  126. package/src/templates-process-explorer.stories.tsx +1304 -0
  127. package/src/test/contract.test.ts +66 -0
  128. package/src/test/contract.ts +107 -6
  129. package/src/test/doubles.test.tsx +87 -1
  130. package/src/test/doubles.tsx +174 -3
  131. package/src/test/index.ts +25 -1
  132. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  133. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  134. package/src/variant-explorer/coverage-bar.tsx +36 -0
  135. package/src/variant-explorer/index.ts +16 -0
  136. package/src/variant-explorer/sequence-chips.tsx +103 -0
  137. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  138. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  139. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  140. package/src/variant-explorer/variant-explorer.tsx +567 -0
  141. package/src/variant-explorer/variant-row.tsx +137 -0
  142. package/src/violation-list/index.ts +2 -0
  143. package/src/violation-list/violation-list.stories.tsx +73 -0
  144. package/src/violation-list/violation-list.test.tsx +84 -0
  145. package/src/violation-list/violation-list.tsx +259 -0
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { casesFromLog } from "./cases-from-log";
4
+ import { extractVariants } from "./extract-variants";
5
+ import fixture from "./fixtures/order-to-cash-small.json";
6
+ import type { EventLog } from "./types";
7
+
8
+ const orderToCash = fixture as EventLog;
9
+
10
+ describe("casesFromLog", () => {
11
+ it("answers an empty log with an empty array", () => {
12
+ expect(casesFromLog({ events: [] })).toEqual([]);
13
+ });
14
+
15
+ it("summarizes each case's own extent, size and duration", () => {
16
+ const rows = casesFromLog(orderToCash);
17
+ expect(rows.map((r) => r.caseId)).toEqual(["case-1", "case-2", "case-3", "case-4", "case-5"]);
18
+
19
+ const case1 = rows.find((r) => r.caseId === "case-1");
20
+ expect(case1).toMatchObject({
21
+ start: "2026-01-05T09:00:00.000Z",
22
+ end: "2026-01-05T14:00:00.000Z",
23
+ durationMs: 18_000_000,
24
+ eventCount: 6,
25
+ });
26
+ });
27
+
28
+ it("assigns the SAME variantId extractVariants assigns the same case", () => {
29
+ const rows = casesFromLog(orderToCash);
30
+ const variants = extractVariants(orderToCash);
31
+
32
+ for (const row of rows) {
33
+ const variant = variants.find((v) => v.caseIds.includes(row.caseId));
34
+ expect(row.variantId).toBe(variant?.id);
35
+ }
36
+
37
+ // case-1 and case-2 share the five-step happy path — same variant id.
38
+ const case1 = rows.find((r) => r.caseId === "case-1");
39
+ const case2 = rows.find((r) => r.caseId === "case-2");
40
+ expect(case1?.variantId).toBe(case2?.variantId);
41
+ expect(case1?.variantId).toBeTruthy();
42
+
43
+ // case-3 (rejected early) follows a different path.
44
+ const case3 = rows.find((r) => r.caseId === "case-3");
45
+ expect(case3?.variantId).not.toBe(case1?.variantId);
46
+ });
47
+
48
+ it("never invents a conformance value", () => {
49
+ const rows = casesFromLog(orderToCash);
50
+ expect(rows.every((r) => r.conformance === undefined)).toBe(true);
51
+ });
52
+
53
+ it("carries per-case attributes through untouched", () => {
54
+ const log: EventLog = {
55
+ events: [{ caseId: "c1", activity: "A", timestamp: 0 }],
56
+ caseAttributes: { c1: { region: "EU", priority: 2 } },
57
+ };
58
+ const rows = casesFromLog(log);
59
+ expect(rows[0]?.attributes).toEqual({ region: "EU", priority: 2 });
60
+ });
61
+
62
+ it("answers an empty ISO start/end for a case with no resolvable extent", () => {
63
+ // A "complete" row whose activity never opened and no explicit startTimestamp still
64
+ // resolves — the only way to get NaN is an unparseable timestamp on every row.
65
+ const log: EventLog = {
66
+ events: [{ caseId: "c1", activity: "A", timestamp: "not-a-date" }],
67
+ };
68
+ const rows = casesFromLog(log);
69
+ expect(rows[0]?.start).toBe("");
70
+ expect(rows[0]?.end).toBe("");
71
+ });
72
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * casesFromLog — RM-055.
3
+ *
4
+ * A pure per-case summary over an event log: one {@link CaseRow} per case, shaped for
5
+ * `CaseTable`'s default columns and CSV export (`toCsv`). `variantId` is read off
6
+ * {@link extractVariants} rather than recomputed independently — `extractVariants` is the
7
+ * one place that disambiguates a 64-bit hash collision between two different sequences
8
+ * with a `-1`/`-2` suffix, so recomputing `variantId(sequence)` here directly would
9
+ * silently diverge from a variant explorer over the SAME log on the rare log where that
10
+ * disambiguation fires. Reading it off `extractVariants`'s own `caseIds` keeps the two
11
+ * views byte-identical always, not just usually.
12
+ *
13
+ * Framework-free, deterministic: no React, no `Date.now()`, no randomness. See
14
+ * `.claude/rules/data.md` — nothing under `src/core/` may import React or an
15
+ * `@elabs-ai/components-*` package.
16
+ */
17
+ import { asNormalizedLog, type NormalizedCase } from "./event-log";
18
+ import { extractVariants } from "./extract-variants";
19
+ import type { EventLog } from "./types";
20
+
21
+ /**
22
+ * One case's own summary — its extent, size and path identity, not its trace. The shape
23
+ * `CaseTable` (case-table/) renders one row per, and `CaseTimeline`'s own model reads a
24
+ * single case's raw `EventRow[]` separately (a summary row has no per-activity detail to
25
+ * build a Gantt row from).
26
+ */
27
+ export interface CaseRow {
28
+ caseId: string;
29
+ /** ISO 8601. Empty string when the case has no resolvable extent — see `normalizeLog`. */
30
+ start: string;
31
+ end: string;
32
+ durationMs: number;
33
+ eventCount: number;
34
+ /** Identical to the `id` {@link extractVariants} assigns the SAME case over the SAME log. */
35
+ variantId: string;
36
+ /**
37
+ * Never set here: no conformance model lives in `/core` (`ProcessKpiStrip`'s own
38
+ * `conformance` prop treats it the same way — a fitted value the HOST supplies, never a
39
+ * number this package invents). A caller with a conformance result attaches it per row.
40
+ */
41
+ conformance?: "conforming" | "nonConforming" | "unknown";
42
+ /** Carried over from `EventLog.caseAttributes`, untouched. */
43
+ attributes?: Record<string, string | number | boolean | null>;
44
+ }
45
+
46
+ /** `NaN` (no resolvable extent) becomes `""`, never a thrown `RangeError` or `"Invalid Date"`. */
47
+ function toIso(ms: number): string {
48
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : "";
49
+ }
50
+
51
+ function toCaseRow(kase: NormalizedCase, variantId: string): CaseRow {
52
+ const row: CaseRow = {
53
+ caseId: kase.caseId,
54
+ start: toIso(kase.start),
55
+ end: toIso(kase.end),
56
+ durationMs: Number.isFinite(kase.duration) ? kase.duration : 0,
57
+ eventCount: kase.events.length,
58
+ variantId,
59
+ };
60
+ if (kase.attributes !== undefined) {
61
+ row.attributes = kase.attributes as Record<string, string | number | boolean | null>;
62
+ }
63
+ return row;
64
+ }
65
+
66
+ /**
67
+ * One summary row per case in `log`, in the same first-appearance order `normalizeLog`
68
+ * produces. `variantId` is sourced from {@link extractVariants} over the identical log —
69
+ * see the module docblock for why that, and not a direct `variantId(sequence)` call, is
70
+ * what keeps a `CaseTable` and a variant explorer over the same log always agreeing on
71
+ * which cases share a path.
72
+ *
73
+ * An empty log answers an empty array.
74
+ */
75
+ export function casesFromLog(log: EventLog): CaseRow[] {
76
+ const normalized = asNormalizedLog(log);
77
+ if (normalized.cases.length === 0) return [];
78
+
79
+ const variantIdByCase = new Map<string, string>();
80
+ for (const variant of extractVariants(normalized)) {
81
+ for (const caseId of variant.caseIds) variantIdByCase.set(caseId, variant.id);
82
+ }
83
+
84
+ return normalized.cases.map((kase) => toCaseRow(kase, variantIdByCase.get(kase.caseId) ?? ""));
85
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { conformanceRateSeries } from "./conformance";
4
+ import * as core from "./index";
5
+ import { liftHappyPath } from "./reference-model";
6
+ import type { EventLog, EventRow } from "./types";
7
+
8
+ const model = liftHappyPath({
9
+ id: "p",
10
+ label: "Path",
11
+ steps: [{ activity: "A" }, { activity: "B" }, { activity: "C" }],
12
+ });
13
+
14
+ function caseRows(caseId: string, start: number, trace: string[]): EventRow[] {
15
+ return trace.map((activity, i) => ({ caseId, activity, timestamp: start + i * 3_600_000 }));
16
+ }
17
+
18
+ // Three months: January all conforming, February one of two, March one incomplete case.
19
+ const log: EventLog = {
20
+ events: [
21
+ ...caseRows("jan-1", Date.UTC(2026, 0, 3), ["A", "B", "C"]),
22
+ ...caseRows("jan-2", Date.UTC(2026, 0, 20), ["A", "B", "C"]),
23
+ ...caseRows("feb-1", Date.UTC(2026, 1, 2), ["A", "B", "C"]),
24
+ ...caseRows("feb-2", Date.UTC(2026, 1, 14), ["A", "B"]),
25
+ ...caseRows("mar-1", Date.UTC(2026, 2, 30), ["A", "B"]),
26
+ ],
27
+ };
28
+
29
+ describe("conformanceRateSeries", () => {
30
+ it("returns one bucket per month whose case counts sum to the log total", () => {
31
+ const series = conformanceRateSeries(log, model, "month");
32
+ expect(series.map((p) => p.bucket)).toEqual(["2026-01", "2026-02", "2026-03"]);
33
+ expect(series.map((p) => p.caseCount)).toEqual([2, 2, 1]);
34
+ expect(series.reduce((sum, p) => sum + p.caseCount, 0)).toBe(5);
35
+ // A, B then stop: p=3, c=3, m=1, r=1 → ½(1−1/3) + ½(1−1/3) = 2/3.
36
+ expect(series[0]?.fitness).toBe(1);
37
+ expect(series[1]?.fitness).toBeCloseTo((1 + 2 / 3) / 2, 12);
38
+ expect(series[2]?.fitness).toBeCloseTo(2 / 3, 12);
39
+ });
40
+
41
+ it("buckets by UTC day and by the UTC Monday of the week", () => {
42
+ expect(conformanceRateSeries(log, model, "day").map((p) => p.bucket)).toEqual([
43
+ "2026-01-03",
44
+ "2026-01-20",
45
+ "2026-02-02",
46
+ "2026-02-14",
47
+ "2026-03-30",
48
+ ]);
49
+ // 2026-01-03 is a Saturday → week of Monday 2025-12-29; 2026-03-30 is itself a Monday.
50
+ expect(conformanceRateSeries(log, model, "week").map((p) => p.bucket)).toEqual([
51
+ "2025-12-29",
52
+ "2026-01-19",
53
+ "2026-02-02",
54
+ "2026-02-09",
55
+ "2026-03-30",
56
+ ]);
57
+ });
58
+
59
+ it("leaves out a case with no resolvable start instead of inventing a bucket", () => {
60
+ const withBad: EventLog = {
61
+ events: [...log.events, { caseId: "bad", activity: "A", timestamp: "garbage" }],
62
+ };
63
+ const total = conformanceRateSeries(withBad, model, "month").reduce(
64
+ (sum, p) => sum + p.caseCount,
65
+ 0,
66
+ );
67
+ expect(total).toBe(5);
68
+ });
69
+ });
70
+
71
+ describe("core scope boundary (analysis §9 risk 2)", () => {
72
+ it("exports the replay API and no alignment search or BPMN import", () => {
73
+ expect(typeof core.liftHappyPath).toBe("function");
74
+ expect(typeof core.replayTrace).toBe("function");
75
+ expect(typeof core.tokenReplay).toBe("function");
76
+ expect(typeof core.conformanceRateSeries).toBe("function");
77
+ const forbidden = Object.keys(core).filter((name) => /align|bpmn|petri/i.test(name));
78
+ expect(forbidden).toEqual([]);
79
+ });
80
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Conformance model — RM-061.
3
+ *
4
+ * The log-level result of replaying an event log against a reference model
5
+ * ({@link ConformanceResult}, produced by `tokenReplay`) and the fitness-over-time series a
6
+ * KPI sparkline plots ({@link conformanceRateSeries}).
7
+ *
8
+ * Scope boundary (analysis §9 risk 2): token replay against a lifted happy path only — no
9
+ * alignments, no BPMN import. A host needing either brings a backend.
10
+ *
11
+ * Framework-free, deterministic: no React, no `Date.now()`, no `@elabs-ai/components-*`
12
+ * import.
13
+ */
14
+ import { asNormalizedLog, type AnyLog } from "./event-log";
15
+ import type { ReplayModel } from "./reference-model";
16
+ import { replayActivities, type DeviationType, type TraceReplayResult } from "./token-replay";
17
+
18
+ /** Log-level conformance of an event log against a reference model. */
19
+ export interface ConformanceResult {
20
+ /** Mean trace fitness in `[0, 1]`; `0` for an empty log. */
21
+ overallFitness: number;
22
+ /** One result per case, in the log's case order. */
23
+ traces: TraceReplayResult[];
24
+ /** Total deviations per type across all cases; every type is present. */
25
+ deviationCounts: Record<DeviationType, number>;
26
+ /** Deviations charged to each activity. Sums to the total deviation count. */
27
+ perActivity: Record<string, { deviations: number }>;
28
+ /**
29
+ * Deviations charged to each OBSERVED directly-follows edge, keyed
30
+ * `source + EDGE_KEY_SEPARATOR + target` — the same key `discoverGraph` gives the edge.
31
+ * Sums to at most the total deviation count.
32
+ */
33
+ perEdge: Record<string, { deviations: number }>;
34
+ }
35
+
36
+ /** Calendar granularity of {@link conformanceRateSeries}. All buckets are UTC. */
37
+ export type ConformanceBucket = "day" | "week" | "month";
38
+
39
+ /** One point of {@link conformanceRateSeries}. */
40
+ export interface ConformanceRatePoint {
41
+ /** `YYYY-MM-DD` (day; for week, the UTC Monday that starts it) or `YYYY-MM` (month). */
42
+ bucket: string;
43
+ /** Mean fitness of the cases that started in this bucket. */
44
+ fitness: number;
45
+ caseCount: number;
46
+ }
47
+
48
+ const DAY_MS = 86_400_000;
49
+
50
+ function bucketOf(ms: number, bucket: ConformanceBucket): string {
51
+ const date = new Date(ms);
52
+ if (bucket === "week") {
53
+ // getUTCDay(): 0 = Sunday … 6 = Saturday; shift so Monday is day 0.
54
+ const offset = (date.getUTCDay() + 6) % 7;
55
+ const monday = new Date(
56
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) - offset * DAY_MS,
57
+ );
58
+ return monday.toISOString().slice(0, 10);
59
+ }
60
+ const iso = date.toISOString();
61
+ return bucket === "month" ? iso.slice(0, 7) : iso.slice(0, 10);
62
+ }
63
+
64
+ /**
65
+ * Mean replay fitness per calendar bucket, keyed by each case's START time, in ascending
66
+ * bucket order. Empty buckets are not emitted. A case with no resolvable start timestamp
67
+ * cannot be placed in time and is left out, so `caseCount`s sum to the number of cases
68
+ * with a valid start.
69
+ */
70
+ export function conformanceRateSeries(
71
+ log: AnyLog,
72
+ model: ReplayModel,
73
+ bucket: ConformanceBucket,
74
+ ): ConformanceRatePoint[] {
75
+ const totals = new Map<string, { sum: number; count: number }>();
76
+ for (const kase of asNormalizedLog(log).cases) {
77
+ if (!Number.isFinite(kase.start)) continue;
78
+ const key = bucketOf(kase.start, bucket);
79
+ const trace = kase.events.map((event) => event.activity);
80
+ const { fitness } = replayActivities(kase.caseId, trace, model);
81
+ const entry = totals.get(key);
82
+ if (entry === undefined) totals.set(key, { sum: fitness, count: 1 });
83
+ else {
84
+ entry.sum += fitness;
85
+ entry.count += 1;
86
+ }
87
+ }
88
+ return [...totals.entries()]
89
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
90
+ .map(([key, { sum, count }]) => ({ bucket: key, fitness: sum / count, caseCount: count }));
91
+ }
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { diffGraphs } from "./diff-graphs";
3
+ import type { ActivityStats, ProcessGraph, TransitionStats } from "./types";
4
+
5
+ const DURATION = {
6
+ min: 1,
7
+ max: 10,
8
+ mean: 5,
9
+ median: 5,
10
+ p90: 9,
11
+ sum: 50,
12
+ trimmedMean: 5,
13
+ };
14
+
15
+ function activity(id: string, overrides: Partial<ActivityStats> = {}): ActivityStats {
16
+ return {
17
+ id,
18
+ label: id,
19
+ instances: 10,
20
+ cases: 5,
21
+ isStart: false,
22
+ isEnd: false,
23
+ duration: DURATION,
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ function transition(
29
+ source: string,
30
+ target: string,
31
+ overrides: Partial<TransitionStats> = {},
32
+ ): TransitionStats {
33
+ return {
34
+ source,
35
+ target,
36
+ count: 10,
37
+ caseCount: 5,
38
+ duration: DURATION,
39
+ isSelfLoop: source === target,
40
+ isBackEdge: false,
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ function graph(overrides: Partial<ProcessGraph> = {}): ProcessGraph {
46
+ return {
47
+ activities: [],
48
+ transitions: [],
49
+ startActivities: {},
50
+ endActivities: {},
51
+ totals: { cases: 0, events: 0, variants: 0 },
52
+ ...overrides,
53
+ };
54
+ }
55
+
56
+ describe("diffGraphs", () => {
57
+ it("marks every entry common with delta 0 on two identical graphs", () => {
58
+ const a = graph({
59
+ activities: [activity("Create", { isStart: true }), activity("Ship", { isEnd: true })],
60
+ transitions: [transition("Create", "Ship")],
61
+ });
62
+ const b = graph({
63
+ activities: [activity("Create", { isStart: true }), activity("Ship", { isEnd: true })],
64
+ transitions: [transition("Create", "Ship")],
65
+ });
66
+
67
+ const diff = diffGraphs(a, b);
68
+
69
+ expect(diff.activities).toHaveLength(2);
70
+ for (const entry of diff.activities) {
71
+ expect(entry.state).toBe("common");
72
+ expect(entry.delta).toBe(0);
73
+ expect(entry.ratio).toBe(1);
74
+ }
75
+ expect(diff.transitions).toHaveLength(1);
76
+ expect(diff.transitions[0]!.state).toBe("common");
77
+ expect(diff.transitions[0]!.delta).toBe(0);
78
+ });
79
+
80
+ it("marks every entry aOnly or bOnly on fully disjoint graphs", () => {
81
+ const a = graph({
82
+ activities: [activity("Create")],
83
+ transitions: [transition("Create", "Create")],
84
+ });
85
+ const b = graph({
86
+ activities: [activity("Cancel")],
87
+ transitions: [transition("Cancel", "Cancel")],
88
+ });
89
+
90
+ const diff = diffGraphs(a, b);
91
+
92
+ expect(diff.activities).toHaveLength(2);
93
+ // A one-sided entry never sets its ABSENT side's key at all (no `b: undefined` noise) —
94
+ // check presence/absence directly rather than via `toMatchObject`, which treats an
95
+ // expected `undefined` value inconsistently with a genuinely missing key.
96
+ const createEntry = diff.activities.find((e) => e.id === "Create");
97
+ expect(createEntry).toMatchObject({ state: "aOnly", a: expect.any(Object) });
98
+ expect(createEntry?.b).toBeUndefined();
99
+ expect(createEntry?.delta).toBeUndefined();
100
+ const cancelEntry = diff.activities.find((e) => e.id === "Cancel");
101
+ expect(cancelEntry).toMatchObject({ state: "bOnly", b: expect.any(Object) });
102
+ expect(cancelEntry?.a).toBeUndefined();
103
+ expect(cancelEntry?.delta).toBeUndefined();
104
+ expect(diff.transitions).toHaveLength(2);
105
+ for (const entry of diff.transitions) {
106
+ expect(["aOnly", "bOnly"]).toContain(entry.state);
107
+ expect(entry.delta).toBeUndefined();
108
+ }
109
+ });
110
+
111
+ it("computes delta and ratio for a common entry whose value changed", () => {
112
+ const a = graph({ activities: [activity("Create", { instances: 20 })] });
113
+ const b = graph({ activities: [activity("Create", { instances: 30 })] });
114
+
115
+ const diff = diffGraphs(a, b);
116
+
117
+ expect(diff.activities[0]).toMatchObject({ state: "common", delta: 10, ratio: 1.5 });
118
+ });
119
+
120
+ it("omits ratio (never Infinity) when a's reference value is zero", () => {
121
+ const a = graph({ activities: [activity("Create", { instances: 0 })] });
122
+ const b = graph({ activities: [activity("Create", { instances: 5 })] });
123
+
124
+ const diff = diffGraphs(a, b);
125
+
126
+ expect(diff.activities[0]!.delta).toBe(5);
127
+ expect(diff.activities[0]!.ratio).toBeUndefined();
128
+ });
129
+
130
+ it("carries each side's own totals through untouched", () => {
131
+ const a = graph({ totals: { cases: 3, events: 9, variants: 1 } });
132
+ const b = graph({ totals: { cases: 7, events: 21, variants: 2 } });
133
+
134
+ const diff = diffGraphs(a, b);
135
+
136
+ expect(diff.totals).toEqual({
137
+ a: { cases: 3, events: 9, variants: 1 },
138
+ b: { cases: 7, events: 21, variants: 2 },
139
+ });
140
+ });
141
+
142
+ it("keys a transition by source+target, not by object identity", () => {
143
+ const a = graph({ transitions: [transition("A", "B", { count: 4 })] });
144
+ const b = graph({ transitions: [transition("A", "B", { count: 9 })] });
145
+
146
+ const diff = diffGraphs(a, b);
147
+
148
+ expect(diff.transitions).toHaveLength(1);
149
+ expect(diff.transitions[0]).toMatchObject({ state: "common", delta: 5 });
150
+ });
151
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * diffGraphs — RM-064's pure diff between two discovered `ProcessGraph`s, the model
3
+ * `ProcessCompare` renders (side-by-side or superimposed).
4
+ *
5
+ * Activities are matched by `id` (the activity name — the same identity `/core` uses
6
+ * everywhere else); transitions are matched by `source`+{@link EDGE_KEY_SEPARATOR}+`target`,
7
+ * exactly the key `reconcileGraph`'s own `transitionKey` builds, so a transition that moved
8
+ * between the two graphs is never mistaken for two unrelated ones. An element present on
9
+ * both sides is `"common"`; present only in `a` is `"aOnly"`; present only in `b` is
10
+ * `"bOnly"` — every element in the union appears in exactly one of the three states.
11
+ *
12
+ * `delta`/`ratio` are computed once BOTH sides have the element (a `"common"` entry) —
13
+ * comparing a real reading against nothing would be a fabricated number, not a diff. The
14
+ * reference metric is each element's plain occurrence count — `instances` for an activity,
15
+ * `count` for a transition — because those are the only two fields every `ActivityStats`/
16
+ * `TransitionStats` always carries (never derived, never optional), so a delta is always
17
+ * well-defined for a `"common"` entry, whatever metric a downstream view happens to paint
18
+ * with. `ratio` is omitted rather than `Infinity` when `a`'s value is `0`.
19
+ *
20
+ * Pure, deterministic, framework-free — see the module-level rule in `types.ts`.
21
+ */
22
+ import { EDGE_KEY_SEPARATOR } from "./discover-graph";
23
+ import type { ActivityStats, ProcessGraph, TransitionStats } from "./types";
24
+
25
+ /** Which side(s) of the diff an activity or transition survives in. */
26
+ export type DiffState = "common" | "aOnly" | "bOnly";
27
+
28
+ /** One activity's or transition's diff — its identity, its state, and (when `"common"`) its delta. */
29
+ export interface DiffEntry<Stats> {
30
+ id: string;
31
+ state: DiffState;
32
+ /** Present unless the element is `"bOnly"`. */
33
+ a?: Stats;
34
+ /** Present unless the element is `"aOnly"`. */
35
+ b?: Stats;
36
+ /** `b`'s reference value minus `a`'s. Only set for a `"common"` entry. */
37
+ delta?: number;
38
+ /** `b`'s reference value divided by `a`'s. Only set for a `"common"` entry with `a > 0`. */
39
+ ratio?: number;
40
+ }
41
+
42
+ /** The full diff between two graphs. */
43
+ export interface ProcessGraphDiff {
44
+ activities: DiffEntry<ActivityStats>[];
45
+ transitions: DiffEntry<TransitionStats>[];
46
+ totals: { a: ProcessGraph["totals"]; b: ProcessGraph["totals"] };
47
+ }
48
+
49
+ /** `source`+{@link EDGE_KEY_SEPARATOR}+`target` — the same key `reconcileGraph` builds. */
50
+ function transitionKey(transition: Pick<TransitionStats, "source" | "target">): string {
51
+ return `${transition.source}${EDGE_KEY_SEPARATOR}${transition.target}`;
52
+ }
53
+
54
+ /** Every id in `first`, in order, then every id `second` adds that `first` did not have. */
55
+ function unionIds(first: string[], second: string[]): string[] {
56
+ const seen = new Set<string>();
57
+ const out: string[] = [];
58
+ for (const id of first) {
59
+ if (seen.has(id)) continue;
60
+ seen.add(id);
61
+ out.push(id);
62
+ }
63
+ for (const id of second) {
64
+ if (seen.has(id)) continue;
65
+ seen.add(id);
66
+ out.push(id);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ function diffEntries<Stats>(
72
+ aById: ReadonlyMap<string, Stats>,
73
+ bById: ReadonlyMap<string, Stats>,
74
+ ids: string[],
75
+ referenceValue: (stats: Stats) => number,
76
+ ): DiffEntry<Stats>[] {
77
+ return ids.map((id) => {
78
+ const a = aById.get(id);
79
+ const b = bById.get(id);
80
+ const state: DiffState = a !== undefined && b !== undefined ? "common" : a ? "aOnly" : "bOnly";
81
+ const entry: DiffEntry<Stats> = { id, state };
82
+ if (a !== undefined) entry.a = a;
83
+ if (b !== undefined) entry.b = b;
84
+ if (a !== undefined && b !== undefined) {
85
+ const aValue = referenceValue(a);
86
+ const bValue = referenceValue(b);
87
+ entry.delta = bValue - aValue;
88
+ if (aValue > 0) entry.ratio = bValue / aValue;
89
+ }
90
+ return entry;
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Diff two discovered graphs. Neither input is mutated; the entries reference the original
96
+ * `ActivityStats`/`TransitionStats` objects, never copies.
97
+ */
98
+ export function diffGraphs(a: ProcessGraph, b: ProcessGraph): ProcessGraphDiff {
99
+ const aActivities = new Map(a.activities.map((activity) => [activity.id, activity]));
100
+ const bActivities = new Map(b.activities.map((activity) => [activity.id, activity]));
101
+ const activityIds = unionIds(
102
+ a.activities.map((activity) => activity.id),
103
+ b.activities.map((activity) => activity.id),
104
+ );
105
+
106
+ const aTransitions = new Map(a.transitions.map((t) => [transitionKey(t), t]));
107
+ const bTransitions = new Map(b.transitions.map((t) => [transitionKey(t), t]));
108
+ const transitionIds = unionIds(
109
+ a.transitions.map((t) => transitionKey(t)),
110
+ b.transitions.map((t) => transitionKey(t)),
111
+ );
112
+
113
+ return {
114
+ activities: diffEntries(aActivities, bActivities, activityIds, (stats) => stats.instances),
115
+ transitions: diffEntries(aTransitions, bTransitions, transitionIds, (stats) => stats.count),
116
+ totals: { a: a.totals, b: b.totals },
117
+ };
118
+ }