@elabs-ai/components-process 4.2.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/dist/core/index.d.ts +801 -3
- package/dist/core/index.js +1334 -0
- package/dist/core/index.js.map +1 -1
- package/dist/index.d.ts +1889 -34
- package/dist/index.js +5512 -196
- package/dist/index.js.map +1 -1
- package/dist/test/index.d.ts +223 -5
- package/dist/test/index.js +346 -191
- package/dist/test/index.js.map +1 -1
- package/package.json +14 -13
- package/src/__contract__/case-table.contract.test.tsx +49 -0
- package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
- package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
- package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
- package/src/__contract__/violation-list.contract.test.tsx +49 -0
- package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
- package/src/abstraction-controls/abstraction-controls.tsx +198 -5
- package/src/case-table/case-table.stories.tsx +89 -0
- package/src/case-table/case-table.test.tsx +148 -0
- package/src/case-table/case-table.tsx +144 -0
- package/src/case-table/columns.ts +116 -0
- package/src/case-table/index.ts +11 -0
- package/src/case-timeline/case-timeline-model.test.ts +72 -0
- package/src/case-timeline/case-timeline-model.ts +112 -0
- package/src/case-timeline/case-timeline.stories.tsx +94 -0
- package/src/case-timeline/case-timeline.test.tsx +51 -0
- package/src/case-timeline/case-timeline.tsx +109 -0
- package/src/case-timeline/index.ts +9 -0
- package/src/conformance-overlay/conformance-fixture.ts +59 -0
- package/src/conformance-overlay/conformance-legend.tsx +109 -0
- package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
- package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
- package/src/conformance-overlay/conformance-overlay.tsx +107 -0
- package/src/conformance-overlay/conformance-state.test.ts +79 -0
- package/src/conformance-overlay/conformance-state.ts +220 -0
- package/src/conformance-overlay/index.ts +4 -0
- package/src/core/activity-color-scale.test.ts +107 -0
- package/src/core/activity-color-scale.ts +133 -0
- package/src/core/adapters/ocel.test.ts +112 -0
- package/src/core/adapters/ocel.ts +359 -0
- package/src/core/adapters/xes.test.ts +293 -0
- package/src/core/adapters/xes.ts +384 -0
- package/src/core/cases-from-log.test.ts +72 -0
- package/src/core/cases-from-log.ts +85 -0
- package/src/core/conformance.test.ts +80 -0
- package/src/core/conformance.ts +91 -0
- package/src/core/diff-graphs.test.ts +151 -0
- package/src/core/diff-graphs.ts +118 -0
- package/src/core/discover-object-centric-graph.test.ts +94 -0
- package/src/core/discover-object-centric-graph.ts +296 -0
- package/src/core/fixtures/ocel-sample.ts +82 -0
- package/src/core/fixtures/sample.xes +68 -0
- package/src/core/index.ts +113 -0
- package/src/core/reference-model.test.ts +43 -0
- package/src/core/reference-model.ts +116 -0
- package/src/core/replay-timeline.test.ts +161 -0
- package/src/core/replay-timeline.ts +260 -0
- package/src/core/segments.test.ts +185 -0
- package/src/core/segments.ts +153 -0
- package/src/core/token-replay.test.ts +218 -0
- package/src/core/token-replay.ts +456 -0
- package/src/core/types.ts +2 -2
- package/src/dotted-chart/compute-dots.test.ts +176 -0
- package/src/dotted-chart/compute-dots.ts +241 -0
- package/src/dotted-chart/dotted-chart-labels.ts +93 -0
- package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
- package/src/dotted-chart/dotted-chart.test.tsx +135 -0
- package/src/dotted-chart/dotted-chart.tsx +841 -0
- package/src/dotted-chart/index.ts +23 -0
- package/src/dotted-chart/use-element-size.ts +33 -0
- package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
- package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
- package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
- package/src/happy-path-editor/happy-path-editor.tsx +239 -0
- package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
- package/src/happy-path-editor/index.ts +4 -0
- package/src/index.ts +51 -1
- package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
- package/src/performance-spectrum/aggregate-segments.ts +174 -0
- package/src/performance-spectrum/index.ts +25 -0
- package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
- package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
- package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
- package/src/performance-spectrum/performance-spectrum.tsx +870 -0
- package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
- package/src/process-compare/compare-kpi-strip.tsx +94 -0
- package/src/process-compare/compare-model.ts +83 -0
- package/src/process-compare/compare-side.tsx +42 -0
- package/src/process-compare/diff-to-graph.ts +104 -0
- package/src/process-compare/index.ts +23 -0
- package/src/process-compare/process-compare.stories.tsx +184 -0
- package/src/process-compare/process-compare.test.tsx +224 -0
- package/src/process-compare/process-compare.tsx +251 -0
- package/src/process-explorer.stories.tsx +1 -1
- package/src/process-filter-bar/index.ts +2 -0
- package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
- package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
- package/src/process-filter-bar/process-filter-bar.tsx +167 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
- package/src/process-map/activity-accent.ts +25 -0
- package/src/process-map/index.ts +1 -0
- package/src/process-map/map-model.test.ts +16 -0
- package/src/process-map/map-model.ts +323 -1
- package/src/process-map/object-centric-map.test.tsx +132 -0
- package/src/process-map/process-activity-node.tsx +152 -14
- package/src/process-map/process-map-object-centric.stories.tsx +219 -0
- package/src/process-map/process-map.stories.tsx +64 -0
- package/src/process-map/process-map.tsx +240 -16
- package/src/process-map/process-transition-edge.test.tsx +47 -0
- package/src/process-map/process-transition-edge.tsx +134 -7
- package/src/process-map/use-process-layout.ts +30 -9
- package/src/process-replay/congestion-heat.tsx +107 -0
- package/src/process-replay/index.ts +14 -0
- package/src/process-replay/process-replay.stories.tsx +168 -0
- package/src/process-replay/process-replay.test.tsx +170 -0
- package/src/process-replay/process-replay.tsx +285 -0
- package/src/process-replay/replay-controls.tsx +147 -0
- package/src/process-replay/replay-format.ts +83 -0
- package/src/process-replay/replay-tokens-context.ts +30 -0
- package/src/process-replay/use-controllable-value.ts +30 -0
- package/src/templates-process-explorer.stories.tsx +1304 -0
- package/src/test/contract.test.ts +66 -0
- package/src/test/contract.ts +107 -6
- package/src/test/doubles.test.tsx +87 -1
- package/src/test/doubles.tsx +174 -3
- package/src/test/index.ts +25 -1
- package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
- package/src/use-process-explorer/use-process-explorer.ts +34 -2
- package/src/variant-explorer/coverage-bar.tsx +36 -0
- package/src/variant-explorer/index.ts +16 -0
- package/src/variant-explorer/sequence-chips.tsx +103 -0
- package/src/variant-explorer/variant-explorer-model.ts +42 -0
- package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
- package/src/variant-explorer/variant-explorer.test.tsx +302 -0
- package/src/variant-explorer/variant-explorer.tsx +567 -0
- package/src/variant-explorer/variant-row.tsx +137 -0
- package/src/violation-list/index.ts +2 -0
- package/src/violation-list/violation-list.stories.tsx +73 -0
- package/src/violation-list/violation-list.test.tsx +84 -0
- package/src/violation-list/violation-list.tsx +259 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { fromOcel } from "./adapters/ocel";
|
|
4
|
+
import {
|
|
5
|
+
abstractObjectCentricGraph,
|
|
6
|
+
discoverObjectCentricGraph,
|
|
7
|
+
objectCentricProcessGraph,
|
|
8
|
+
objectTypeColorScale,
|
|
9
|
+
} from "./discover-object-centric-graph";
|
|
10
|
+
import { OCEL_SAMPLE } from "./fixtures/ocel-sample";
|
|
11
|
+
|
|
12
|
+
function sampleGraph() {
|
|
13
|
+
const result = fromOcel(OCEL_SAMPLE);
|
|
14
|
+
if (!result.ok) throw new Error("fixture must parse");
|
|
15
|
+
return discoverObjectCentricGraph(result.logs, { objectTypes: result.objectTypes });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("discoverObjectCentricGraph", () => {
|
|
19
|
+
it("merges an activity shared by all three types into one node with three perType entries", () => {
|
|
20
|
+
const graph = sampleGraph();
|
|
21
|
+
const pack = graph.activities.find((activity) => activity.id === "Pack")!;
|
|
22
|
+
expect(pack.perType).toEqual({
|
|
23
|
+
order: { instances: 1, cases: 1 },
|
|
24
|
+
item: { instances: 2, cases: 2 },
|
|
25
|
+
package: { instances: 1, cases: 1 },
|
|
26
|
+
});
|
|
27
|
+
// One OCEL event, however many objects it touched.
|
|
28
|
+
expect(pack.events).toBe(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("keeps an activity present in only one type single-type", () => {
|
|
32
|
+
const graph = sampleGraph();
|
|
33
|
+
const pick = graph.activities.find((activity) => activity.id === "Pick Item")!;
|
|
34
|
+
expect(Object.keys(pick.perType)).toEqual(["item"]);
|
|
35
|
+
expect(pick.perType.item).toEqual({ instances: 1, cases: 1 });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("keeps edges per type — never merged across types", () => {
|
|
39
|
+
const graph = sampleGraph();
|
|
40
|
+
expect(graph.objectTypes).toEqual(["order", "item", "package"]);
|
|
41
|
+
const pairs = (type: string) =>
|
|
42
|
+
graph.transitionsByType[type]!.map((t) => `${t.source}>${t.target}:${t.count}`).sort();
|
|
43
|
+
expect(pairs("order")).toEqual(["Pack>Ship:1", "Place Order>Pack:1"]);
|
|
44
|
+
expect(pairs("item")).toEqual([
|
|
45
|
+
"Pick Item>Pack:1",
|
|
46
|
+
"Place Order>Pack:1",
|
|
47
|
+
"Place Order>Pick Item:1",
|
|
48
|
+
]);
|
|
49
|
+
expect(pairs("package")).toEqual(["Pack>Ship:1"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("flattens to a ProcessGraph that sums shared transitions for layout", () => {
|
|
53
|
+
const flat = objectCentricProcessGraph(sampleGraph());
|
|
54
|
+
expect(flat.activities.map((a) => a.id).sort()).toEqual([
|
|
55
|
+
"Pack",
|
|
56
|
+
"Pick Item",
|
|
57
|
+
"Place Order",
|
|
58
|
+
"Ship",
|
|
59
|
+
]);
|
|
60
|
+
const placeToPack = flat.transitions.find(
|
|
61
|
+
(t) => t.source === "Place Order" && t.target === "Pack",
|
|
62
|
+
)!;
|
|
63
|
+
expect(placeToPack.count).toBe(2);
|
|
64
|
+
expect(flat.totals).toEqual({ cases: 4, events: 4, variants: 4 });
|
|
65
|
+
expect(flat.activities.find((a) => a.id === "Pack")).toMatchObject({ instances: 1, cases: 4 });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("abstracts one type without touching another", () => {
|
|
69
|
+
const graph = sampleGraph();
|
|
70
|
+
const abstracted = abstractObjectCentricGraph(graph, {
|
|
71
|
+
item: { activities: 0.34, paths: 1, keepConnected: false },
|
|
72
|
+
});
|
|
73
|
+
expect(abstracted.transitionsByType.order).toBe(graph.transitionsByType.order);
|
|
74
|
+
expect(abstracted.transitionsByType.item!.length).toBeLessThan(
|
|
75
|
+
graph.transitionsByType.item!.length,
|
|
76
|
+
);
|
|
77
|
+
expect(abstracted.hiddenByType.order).toEqual({ activities: 0, paths: 0 });
|
|
78
|
+
expect(abstracted.hiddenByType.item!.activities).toBeGreaterThan(0);
|
|
79
|
+
// Event counts are carried over, never restated.
|
|
80
|
+
expect(abstracted.activities.find((a) => a.id === "Pack")!.events).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("colours object types with the shared RM-054 scale and a text code per type", () => {
|
|
84
|
+
const scale = objectTypeColorScale(sampleGraph());
|
|
85
|
+
// Ranked by object count: item (2) first, then order and package (1 each) by name.
|
|
86
|
+
expect(scale.legend.map((entry) => [entry.activityId, entry.token])).toEqual([
|
|
87
|
+
["item", "--chart-1"],
|
|
88
|
+
["order", "--chart-2"],
|
|
89
|
+
["package", "--chart-3"],
|
|
90
|
+
]);
|
|
91
|
+
const codes = scale.legend.map((entry) => entry.code);
|
|
92
|
+
expect(new Set(codes).size).toBe(3);
|
|
93
|
+
});
|
|
94
|
+
});
|