@elabs-ai/components-process 4.2.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.
- 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,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XES adapter — RM-063.
|
|
3
|
+
*
|
|
4
|
+
* IEEE 1849-2016's interchange format for event logs: a `<log>` of `<trace>`s of
|
|
5
|
+
* `<event>`s, each carrying `key`/`value` attributes typed by their own element name
|
|
6
|
+
* (`string`, `date`, `int`, `float`, `boolean`). It is the format most public benchmark
|
|
7
|
+
* logs — including the BPI Challenge series RM-049's synthetic fixture is modelled on —
|
|
8
|
+
* actually ship in.
|
|
9
|
+
*
|
|
10
|
+
* Parsing is hand-rolled here on purpose, and NOT behind a `DOMParser` fast path:
|
|
11
|
+
* `/core` must run identically in Node, in a worker and in a browser main thread, and a
|
|
12
|
+
* parser this narrow — six element names, attribute-only values, the five standard XML
|
|
13
|
+
* entities, self-closing tags — does not need a general XML engine. `DOMParser` is
|
|
14
|
+
* unavailable in Node/workers anyway, and branching on its presence would leave one of
|
|
15
|
+
* the two code paths permanently untested. Do not reach for an XML dependency or
|
|
16
|
+
* `DOMParser` here; extend the tokenizer below instead.
|
|
17
|
+
*/
|
|
18
|
+
import type { EventLog, EventRow } from "../types";
|
|
19
|
+
|
|
20
|
+
/** Options for {@link fromXes}. */
|
|
21
|
+
export interface XesParseOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Event attribute keys that jointly identify an activity, applied in order and joined
|
|
24
|
+
* with `"+"` when there is more than one — the same semantics as an XES `<classifier>`
|
|
25
|
+
* element's `keys` attribute. Defaults to the log's own first `<classifier>`, or
|
|
26
|
+
* `["concept:name"]` when the log declares none.
|
|
27
|
+
*/
|
|
28
|
+
classifiers?: string[];
|
|
29
|
+
/**
|
|
30
|
+
* How `lifecycle:transition` maps onto {@link EventRow.lifecycle}. `"standard"`
|
|
31
|
+
* (default) maps the literal value `"start"` to `"start"` and every other XES
|
|
32
|
+
* lifecycle value (`"complete"`, `"schedule"`, `"suspend"`, …) to `"complete"`;
|
|
33
|
+
* `"none"` ignores the attribute entirely, so every event stays atomic.
|
|
34
|
+
*/
|
|
35
|
+
lifecycleModel?: "standard" | "none";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One recoverable problem found while reading a XES document. */
|
|
39
|
+
export interface XesParseError {
|
|
40
|
+
type: "malformed_xml" | "missing_trace" | "missing_concept_name" | "missing_timestamp";
|
|
41
|
+
message: string;
|
|
42
|
+
traceIndex?: number;
|
|
43
|
+
eventIndex?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `fromXes`'s result — mirrors the worker's `{ ok: true, … } | { ok: false, … }` result
|
|
48
|
+
* convention (`worker/process-worker.ts`), because unlike `fromCsv`/`fromFlatRows` (which
|
|
49
|
+
* silently skip an incomplete row) a XES document can be genuinely malformed XML, which
|
|
50
|
+
* has nothing sound left to skip to.
|
|
51
|
+
*/
|
|
52
|
+
export type XesParseResult = { ok: true; log: EventLog } | { ok: false; errors: XesParseError[] };
|
|
53
|
+
|
|
54
|
+
type XesAttributeValue = string | number | boolean | null;
|
|
55
|
+
|
|
56
|
+
/** A parsed XML element, scoped to the tags XES actually uses (see the file header). */
|
|
57
|
+
interface XmlElement {
|
|
58
|
+
tag: string;
|
|
59
|
+
attrs: Record<string, string>;
|
|
60
|
+
children: XmlElement[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Thrown only inside {@link parseXmlDocument}; always caught and turned into `malformed_xml`. */
|
|
64
|
+
class XmlSyntaxError extends Error {}
|
|
65
|
+
|
|
66
|
+
const NAME_STOP = /[\s/>=]/;
|
|
67
|
+
const ENTITY_NAMES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
68
|
+
|
|
69
|
+
/** Resolve `&`, `'`, `'` and friends. Anything unrecognized is left as written. */
|
|
70
|
+
function decodeXmlEntities(text: string): string {
|
|
71
|
+
return text.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, body: string) => {
|
|
72
|
+
if (body[0] === "#") {
|
|
73
|
+
const isHex = body[1] === "x" || body[1] === "X";
|
|
74
|
+
const codePoint = Number.parseInt(body.slice(isHex ? 2 : 1), isHex ? 16 : 10);
|
|
75
|
+
return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : match;
|
|
76
|
+
}
|
|
77
|
+
return ENTITY_NAMES[body] ?? match;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse well-formed XML into a tree of {@link XmlElement}, decoding entities as it goes.
|
|
83
|
+
* Deliberately narrow: no namespaces, no DTDs, no processing-instruction content, no
|
|
84
|
+
* nested-attribute (XES 2.0) support — the trace/event/string/date/int/float/boolean/
|
|
85
|
+
* classifier/global vocabulary this adapter reads needs none of that.
|
|
86
|
+
*/
|
|
87
|
+
function parseXmlDocument(text: string): XmlElement {
|
|
88
|
+
let i = 0;
|
|
89
|
+
const n = text.length;
|
|
90
|
+
|
|
91
|
+
const skipWhitespace = (): void => {
|
|
92
|
+
while (i < n && /\s/.test(text[i] as string)) i += 1;
|
|
93
|
+
};
|
|
94
|
+
const skipUntil = (marker: string, what: string): void => {
|
|
95
|
+
const at = text.indexOf(marker, i);
|
|
96
|
+
if (at === -1) throw new XmlSyntaxError(`unterminated ${what}`);
|
|
97
|
+
i = at + marker.length;
|
|
98
|
+
};
|
|
99
|
+
const readName = (): string => {
|
|
100
|
+
const start = i;
|
|
101
|
+
while (i < n && !NAME_STOP.test(text[i] as string)) i += 1;
|
|
102
|
+
if (i === start) throw new XmlSyntaxError(`expected a name at offset ${start}`);
|
|
103
|
+
return text.slice(start, i);
|
|
104
|
+
};
|
|
105
|
+
const readAttrs = (): Record<string, string> => {
|
|
106
|
+
const attrs: Record<string, string> = {};
|
|
107
|
+
for (;;) {
|
|
108
|
+
skipWhitespace();
|
|
109
|
+
const c = text[i];
|
|
110
|
+
if (c === undefined) throw new XmlSyntaxError("unexpected end of input in a start tag");
|
|
111
|
+
if (c === "/" || c === ">") return attrs;
|
|
112
|
+
const name = readName();
|
|
113
|
+
skipWhitespace();
|
|
114
|
+
if (text[i] !== "=") throw new XmlSyntaxError(`expected "=" after attribute "${name}"`);
|
|
115
|
+
i += 1;
|
|
116
|
+
skipWhitespace();
|
|
117
|
+
const quote = text[i];
|
|
118
|
+
if (quote !== '"' && quote !== "'") {
|
|
119
|
+
throw new XmlSyntaxError(`expected a quoted value for attribute "${name}"`);
|
|
120
|
+
}
|
|
121
|
+
i += 1;
|
|
122
|
+
const start = i;
|
|
123
|
+
while (i < n && text[i] !== quote) i += 1;
|
|
124
|
+
if (i >= n) throw new XmlSyntaxError(`unterminated value for attribute "${name}"`);
|
|
125
|
+
attrs[name] = decodeXmlEntities(text.slice(start, i));
|
|
126
|
+
i += 1;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const readElement = (): XmlElement => {
|
|
130
|
+
i += 1; // consume '<'
|
|
131
|
+
const tag = readName();
|
|
132
|
+
const attrs = readAttrs();
|
|
133
|
+
skipWhitespace();
|
|
134
|
+
if (text[i] === "/" && text[i + 1] === ">") {
|
|
135
|
+
i += 2;
|
|
136
|
+
return { tag, attrs, children: [] };
|
|
137
|
+
}
|
|
138
|
+
if (text[i] !== ">") throw new XmlSyntaxError(`expected ">" closing "<${tag}>"`);
|
|
139
|
+
i += 1;
|
|
140
|
+
const children: XmlElement[] = [];
|
|
141
|
+
for (;;) {
|
|
142
|
+
const lt = text.indexOf("<", i);
|
|
143
|
+
if (lt === -1) throw new XmlSyntaxError(`unterminated element "<${tag}>"`);
|
|
144
|
+
i = lt;
|
|
145
|
+
if (text.startsWith("<!--", i)) {
|
|
146
|
+
skipUntil("-->", "comment");
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (text.startsWith("<![CDATA[", i)) {
|
|
150
|
+
skipUntil("]]>", "CDATA section");
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (text.startsWith("</", i)) {
|
|
154
|
+
i += 2;
|
|
155
|
+
const closeName = readName();
|
|
156
|
+
skipWhitespace();
|
|
157
|
+
if (text[i] !== ">") throw new XmlSyntaxError(`expected ">" closing "</${closeName}>"`);
|
|
158
|
+
i += 1;
|
|
159
|
+
if (closeName !== tag) {
|
|
160
|
+
throw new XmlSyntaxError(
|
|
161
|
+
`mismatched closing tag: expected "</${tag}>", found "</${closeName}>"`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return { tag, attrs, children };
|
|
165
|
+
}
|
|
166
|
+
children.push(readElement());
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
for (;;) {
|
|
171
|
+
skipWhitespace();
|
|
172
|
+
if (i >= n) throw new XmlSyntaxError("empty document");
|
|
173
|
+
if (text.startsWith("<?", i)) {
|
|
174
|
+
skipUntil("?>", "processing instruction");
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (text.startsWith("<!--", i)) {
|
|
178
|
+
skipUntil("-->", "comment");
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (text.startsWith("<!", i)) {
|
|
182
|
+
skipUntil(">", "declaration");
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
if (text[i] !== "<") throw new XmlSyntaxError("expected a root element");
|
|
188
|
+
return readElement();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const XES_VALUE_TAGS = new Set(["string", "date", "int", "float", "boolean"]);
|
|
192
|
+
|
|
193
|
+
/** Coerce one `<string|date|int|float|boolean key="…" value="…">` into a typed value. */
|
|
194
|
+
function readXesValue(element: XmlElement): XesAttributeValue {
|
|
195
|
+
const raw = element.attrs.value;
|
|
196
|
+
if (raw === undefined) return null;
|
|
197
|
+
if (element.tag === "int") {
|
|
198
|
+
const value = Number.parseInt(raw, 10);
|
|
199
|
+
return Number.isFinite(value) ? value : raw;
|
|
200
|
+
}
|
|
201
|
+
if (element.tag === "float") {
|
|
202
|
+
const value = Number.parseFloat(raw);
|
|
203
|
+
return Number.isFinite(value) ? value : raw;
|
|
204
|
+
}
|
|
205
|
+
if (element.tag === "boolean") return raw.trim().toLowerCase() === "true";
|
|
206
|
+
return raw;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Read every direct `key`/`value` attribute child (`string`/`date`/`int`/`float`/`boolean`). */
|
|
210
|
+
function readAttributeChildren(children: readonly XmlElement[]): Record<string, XesAttributeValue> {
|
|
211
|
+
const result: Record<string, XesAttributeValue> = {};
|
|
212
|
+
for (const child of children) {
|
|
213
|
+
if (!XES_VALUE_TAGS.has(child.tag)) continue;
|
|
214
|
+
const key = child.attrs.key;
|
|
215
|
+
if (key === undefined) continue;
|
|
216
|
+
result[key] = readXesValue(child);
|
|
217
|
+
}
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** `own` values win; a `<global>` default only fills a key `own` never set. */
|
|
222
|
+
function withDefaults(
|
|
223
|
+
own: Record<string, XesAttributeValue>,
|
|
224
|
+
defaults: Record<string, XesAttributeValue> | undefined,
|
|
225
|
+
): Record<string, XesAttributeValue> {
|
|
226
|
+
return defaults === undefined ? own : { ...defaults, ...own };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Merge every `<global scope="…">` of the given scope, in document order. */
|
|
230
|
+
function collectGlobalDefaults(
|
|
231
|
+
root: XmlElement,
|
|
232
|
+
scope: "event" | "trace",
|
|
233
|
+
): Record<string, XesAttributeValue> | undefined {
|
|
234
|
+
let defaults: Record<string, XesAttributeValue> | undefined;
|
|
235
|
+
for (const child of root.children) {
|
|
236
|
+
if (child.tag !== "global" || child.attrs.scope !== scope) continue;
|
|
237
|
+
defaults = withDefaults(readAttributeChildren(child.children), defaults);
|
|
238
|
+
}
|
|
239
|
+
return defaults;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The keys that jointly name an activity: `options.classifiers` when given, else the
|
|
244
|
+
* log's own first `<classifier>`, else the XES default of `concept:name` alone.
|
|
245
|
+
*/
|
|
246
|
+
function resolveClassifierKeys(root: XmlElement, options: XesParseOptions | undefined): string[] {
|
|
247
|
+
if (options?.classifiers !== undefined && options.classifiers.length > 0) {
|
|
248
|
+
return options.classifiers;
|
|
249
|
+
}
|
|
250
|
+
const classifier = root.children.find((child) => child.tag === "classifier");
|
|
251
|
+
const keys = classifier?.attrs.keys?.trim();
|
|
252
|
+
if (keys !== undefined && keys !== "") return keys.split(/\s+/);
|
|
253
|
+
return ["concept:name"];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The literal value `"start"` maps to `"start"`; every other value collapses to `"complete"`. */
|
|
257
|
+
function mapLifecycle(
|
|
258
|
+
raw: XesAttributeValue | undefined,
|
|
259
|
+
model: "standard" | "none",
|
|
260
|
+
): "start" | "complete" | undefined {
|
|
261
|
+
if (typeof raw !== "string" || model === "none") return undefined;
|
|
262
|
+
const value = raw.trim().toLowerCase();
|
|
263
|
+
if (value === "") return undefined;
|
|
264
|
+
return value === "start" ? "start" : "complete";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Read a IEEE 1849 XES document into an {@link EventLog}.
|
|
269
|
+
*
|
|
270
|
+
* Collects every recoverable problem (a trace with no events, an event missing its
|
|
271
|
+
* activity classifier key(s) or `time:timestamp`) rather than stopping at the first one;
|
|
272
|
+
* only malformed XML itself — which leaves nothing sound to keep reading — short-circuits
|
|
273
|
+
* with a single `malformed_xml` error. Output feeds directly into `normalizeLog`, exactly
|
|
274
|
+
* like `fromCsv`/`fromFlatRows` — no XES-specific branching downstream.
|
|
275
|
+
*/
|
|
276
|
+
export function fromXes(source: string, options?: XesParseOptions): XesParseResult {
|
|
277
|
+
let root: XmlElement;
|
|
278
|
+
try {
|
|
279
|
+
root = parseXmlDocument(source);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
282
|
+
return { ok: false, errors: [{ type: "malformed_xml", message }] };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const lifecycleModel = options?.lifecycleModel ?? "standard";
|
|
286
|
+
const classifierKeys = resolveClassifierKeys(root, options);
|
|
287
|
+
const consumedKeys = new Set([
|
|
288
|
+
...classifierKeys,
|
|
289
|
+
"time:timestamp",
|
|
290
|
+
"org:resource",
|
|
291
|
+
"lifecycle:transition",
|
|
292
|
+
]);
|
|
293
|
+
const eventDefaults = collectGlobalDefaults(root, "event");
|
|
294
|
+
const traceDefaults = collectGlobalDefaults(root, "trace");
|
|
295
|
+
|
|
296
|
+
const errors: XesParseError[] = [];
|
|
297
|
+
const events: EventRow[] = [];
|
|
298
|
+
let caseAttributes: Record<string, Record<string, unknown>> | undefined;
|
|
299
|
+
|
|
300
|
+
const traces = root.children.filter((child) => child.tag === "trace");
|
|
301
|
+
traces.forEach((trace, traceIndex) => {
|
|
302
|
+
const traceEvents = trace.children.filter((child) => child.tag === "event");
|
|
303
|
+
if (traceEvents.length === 0) {
|
|
304
|
+
errors.push({ type: "missing_trace", message: "trace has no events", traceIndex });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const { "concept:name": rawCaseId, ...traceValues } = withDefaults(
|
|
309
|
+
readAttributeChildren(trace.children),
|
|
310
|
+
traceDefaults,
|
|
311
|
+
);
|
|
312
|
+
const caseId =
|
|
313
|
+
typeof rawCaseId === "string" && rawCaseId !== "" ? rawCaseId : `trace-${traceIndex}`;
|
|
314
|
+
|
|
315
|
+
if (Object.keys(traceValues).length > 0) {
|
|
316
|
+
caseAttributes ??= {};
|
|
317
|
+
caseAttributes[caseId] = traceValues;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
traceEvents.forEach((eventElement, eventIndex) => {
|
|
321
|
+
const values = withDefaults(readAttributeChildren(eventElement.children), eventDefaults);
|
|
322
|
+
|
|
323
|
+
const activityParts: string[] = [];
|
|
324
|
+
let missingActivityKey = false;
|
|
325
|
+
for (const key of classifierKeys) {
|
|
326
|
+
const value = values[key];
|
|
327
|
+
if (value === undefined || value === null) {
|
|
328
|
+
missingActivityKey = true;
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
activityParts.push(String(value));
|
|
332
|
+
}
|
|
333
|
+
if (missingActivityKey) {
|
|
334
|
+
errors.push({
|
|
335
|
+
type: "missing_concept_name",
|
|
336
|
+
message: "event is missing its classifier key(s)",
|
|
337
|
+
traceIndex,
|
|
338
|
+
eventIndex,
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const rawTimestamp = values["time:timestamp"];
|
|
344
|
+
if (rawTimestamp === undefined || rawTimestamp === null) {
|
|
345
|
+
errors.push({
|
|
346
|
+
type: "missing_timestamp",
|
|
347
|
+
message: "event is missing time:timestamp",
|
|
348
|
+
traceIndex,
|
|
349
|
+
eventIndex,
|
|
350
|
+
});
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const row: EventRow = {
|
|
355
|
+
caseId,
|
|
356
|
+
activity: activityParts.join("+"),
|
|
357
|
+
timestamp: String(rawTimestamp),
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const resource = values["org:resource"];
|
|
361
|
+
if (typeof resource === "string" && resource !== "") row.resource = resource;
|
|
362
|
+
|
|
363
|
+
const lifecycle = mapLifecycle(values["lifecycle:transition"], lifecycleModel);
|
|
364
|
+
if (lifecycle !== undefined) row.lifecycle = lifecycle;
|
|
365
|
+
|
|
366
|
+
const attributes: Record<string, string | number | boolean | null> = {};
|
|
367
|
+
let anyAttribute = false;
|
|
368
|
+
for (const [key, value] of Object.entries(values)) {
|
|
369
|
+
if (consumedKeys.has(key)) continue;
|
|
370
|
+
attributes[key] = value;
|
|
371
|
+
anyAttribute = true;
|
|
372
|
+
}
|
|
373
|
+
if (anyAttribute) row.attributes = attributes;
|
|
374
|
+
|
|
375
|
+
events.push(row);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
380
|
+
|
|
381
|
+
const log: EventLog = { events };
|
|
382
|
+
if (caseAttributes !== undefined) log.caseAttributes = caseAttributes;
|
|
383
|
+
return { ok: true, log };
|
|
384
|
+
}
|
|
@@ -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
|
+
});
|