Metadata-Version: 2.4
Name: pm4py-ucm
Version: 0.7.10
Summary: Use Case Map (URN/UCM) extension for PM4Py — process mining → jUCMNav
Author: pm4py-ucm contributors
License: GPL-3.0-or-later
Project-URL: Homepage, https://github.com/ProcessMining-uOttawa/pm4py-ucm
Project-URL: Documentation, https://processmining-uottawa.github.io/pm4py-ucm/
Project-URL: Issues, https://github.com/ProcessMining-uOttawa/pm4py-ucm/issues
Project-URL: Changelog, https://github.com/ProcessMining-uOttawa/pm4py-ucm/blob/main/CHANGELOG.md
Keywords: process-mining,pm4py,use-case-maps,ucm,urn,jucmnav,requirements-engineering
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: graphviz>=0.20
Requires-Dist: Pillow>=10.0
Requires-Dist: scikit-learn>=1.3
Provides-Extra: pm4py
Requires-Dist: pm4py>=2.7; extra == "pm4py"
Provides-Extra: viz
Requires-Dist: matplotlib>=3.5; extra == "viz"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pm4py>=2.7; extra == "dev"
Requires-Dist: matplotlib>=3.5; extra == "dev"
Requires-Dist: bandit>=1.7; extra == "dev"
Provides-Extra: docs
Requires-Dist: pdoc>=14; extra == "docs"
Dynamic: license-file

<p align="center">
  <img src="web/assets/logo.png" alt="PM4Py-UCM — Process Mining with Use Case Maps" width="380">
</p>

<h1 align="center">pm4py-ucm</h1>

[![tests](https://github.com/ProcessMining-uOttawa/pm4py-ucm/actions/workflows/tests.yml/badge.svg)](https://github.com/ProcessMining-uOttawa/pm4py-ucm/actions/workflows/tests.yml)
[![docs](https://github.com/ProcessMining-uOttawa/pm4py-ucm/actions/workflows/docs.yml/badge.svg)](https://processmining-uottawa.github.io/pm4py-ucm/)
[![Python](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12-blue)](https://www.python.org/)
[![License: GPL v3+](https://img.shields.io/badge/license-GPLv3%2B-blue.svg)](LICENSE)
[![Streamlit V5](https://img.shields.io/badge/Streamlit-V5%20workspace-FF4B4B?logo=streamlit&logoColor=white)](https://pm4py-ucm.streamlit.app/)
[![Streamlit V2](https://img.shields.io/badge/Streamlit-V2%20scenarios-FF4B4B?logo=streamlit&logoColor=white)](https://pm4py-ucm-scenarios.streamlit.app/)

**Use Case Map (UCM) extension for [PM4Py](https://github.com/process-intelligence-solutions/pm4py).**

`pm4py-ucm` adds first-class support for the [Use Case Map][ucm-wiki]
modelling notation — part of the ITU-T Z.151 *User Requirements Notation*
(URN) standard, supported by the open-source [jUCMNav][jucmnav] tool — to
PM4Py-style process-mining workflows.

The package is structured as a **drop-in companion** to PM4Py's existing
BPMN support: every public helper has the same shape (`read_*`, `write_*`,
`discover_*_inductive`, `view_*`, `convert_to_*`) so adopting UCM as an
additional output of process mining is a one-word change in user code.

```python
import pm4py
import pm4py_ucm

log = pm4py.read_xes("running-example.xes")
ucm = pm4py_ucm.discover_ucm_inductive(log)
pm4py_ucm.view_ucm(ucm)
pm4py_ucm.write_ucm(ucm, "running-example.jucm")  # opens in jUCMNav
```

**Ways to get started:**

- [`demo/pm4py_ucm_tutorial.ipynb`](demo/pm4py_ucm_tutorial.ipynb) — end-to-end Jupyter walkthrough on a real claims-payment log (discovery, BPMN/UCM rendering, navigable **SVG** + `.jucm`/`.png`/`.svg` export, performer mining, hierarchical decomposition, `.jucm` round-trips).
- [`demo/scenario_synthesis_tutorial.ipynb`](demo/scenario_synthesis_tutorial.ipynb) — the **scenario-synthesis** layer: concurrency-aware variants, per-loop counters + `LoopEntryGuard`, both OR-fork condition encodings (variant-driven and **data-driven / decision-mining**, with the per-fork accuracy report), decomposed UCMs, and a capstone running both encodings on the claims log.
- [`demo/model_families_tutorial.ipynb`](demo/model_families_tutorial.ipynb) — the **model-family** pipeline on the claims log: attribute detection, partition preview, per-cell mining, stack/matrix rendering, combined export, the dynamic-stub **umbrella** (skeleton, resource variation, path scenarios), **performance overlays** on activities and edges (with the value-scaled **heat-map**), pairwise **comparison**, and the **family statistics report** (FamilyStats + the self-contained interactive HTML file).
- [`demo/dashboards_tutorial.ipynb`](demo/dashboards_tutorial.ipynb) — the **dashboards** layer: the per-case fact table, the metric catalog and computing widgets, filters / targets / scorecard, every **visualisation** (KPI, gauge, histogram, box plot, bar, line, pie/donut, heatmap table), the **ƒ custom-formula language**, and exporting a self-contained interactive HTML dashboard.
- [`demo/sessions_tutorial.ipynb`](demo/sessions_tutorial.ipynb) — **save, share & resume** a project: the config-vs-data-vs-derived principle, the parameter registry and `collect`, a **settings file** vs a self-contained **project bundle**, the versioned **dashboards** envelope, `load` round-trips, and forward-compatibility (unknown keys preserved).
- [`web/streamlit_app_v5.py`](web/streamlit_app_v5.py) — the **V5 web app** (model, scenarios, model families, statistics reports, Dashboards, **plus a global log filter and activity renaming**), hosted at https://pm4py-ucm.streamlit.app/ — click, don't code: upload an XES/CSV, tune the miner, download the result. (The **V2** scenarios app stays at https://pm4py-ucm-scenarios.streamlit.app/.)
- **[API reference](https://processmining-uottawa.github.io/pm4py-ucm/)** — the full browsable docstring documentation (public API + every module), generated by [pdoc](https://pdoc.dev) and published to GitHub Pages on each push to `main`.
- The rest of this README — reference docs for the public API.

[ucm-wiki]: https://en.wikipedia.org/wiki/Use_Case_Maps
[jucmnav]: https://github.com/JUCMNAV/projetseg-update

## Web front-end

The [Streamlit](https://streamlit.io) front-end — now the **V5** app
([`web/streamlit_app_v5.py`](web/streamlit_app_v5.py)) — serves the
deployment: a left-rail workspace over these views (a strict superset of
the earlier four-tab V3 app), with a global **log filter** and **activity
renaming** applied before mining across every view:

- **Model** — upload an event log (XES or CSV), tune the inductive
  miner / decomposition / performer settings interactively, optionally
  **filter the log** (range sliders over activity- and variant-frequency
  ranks, an exclude list, a date range, a **cycle-time percentile** band
  that keeps the fastest or slowest cases by end-to-end duration, and an
  **attribute filter** written in the ƒ metric language — e.g.
  `attr("Channel") == "Web" and duration() > 5`) and **rename or merge
  activities**
  before mining, preview the diagram in UCM or BPMN notation, and download
  the rendered PNG, the `.jucm` file, or the **filtered log itself** as
  XES/CSV. The filter and rename are global — every view below (and its
  exports) mines the same transformed log.

  ![Model view](web/PM4Py-UCM-Model.png)

- **Scenarios** — concurrency-aware variant clustering + one
  executable jUCMNav `ScenarioDef` per variant. Both variant-driven
  and data-driven OR-fork encodings are exposed; the tab surfaces
  headline metrics (variant count, sequence variants, compression
  ratio, fitness %, per-fork condition-mining accuracies) and, behind a
  single **Prepare downloads** button, offers four downloads: the `.jucm`
  with the synthesized scenario group, `variants.csv`,
  `case_variant_map.csv`, and (data-driven mode) `condition_mining.csv`.
  Runs on flat and decomposed UCMs alike.

  ![Scenarios view](web/PM4Py-UCM-Scenarios.png)

- **Family** — a **deterministic advisor** ranks the case attributes by
  discriminative power (which one yields genuinely different processes), then
  pick 1–2 (with per-value filters and a pre-mining coverage heatmap) and mine
  one model per combination. A
  single **Prepare downloads** button then builds the per-cell zip, the
  combined `.jucm`, the dynamic-stub umbrella `.jucm`, the grid PNG, and
  the interactive HTML statistics report — download-only artifacts built
  on request, so browsing the grid stays fast.

  ![Family view](web/PM4Py-UCM-Family.png)

- **Compare** — rank the family members on a heat-mapped statistics
  table and compare any two side by side (models, activity/edge
  deltas, aligned choice shares).

  ![Compare view](web/PM4Py-UCM-Compare.png)

Plus a **Performance overlay** sidebar section (frequencies/times on
activities and edges, applied to every tab's outputs) with an optional
**heat-map** that colours and thickens activities and edges by value. See
[`docs/model_families.md`](docs/model_families.md).

The overlay's default counts are **replay-based**: they measure how often
the log walks each part of the model, so the numbers **conserve** —
an activity's count matches the count on its own edges, parallel branches
all carry their fork's inflow, and a choice's branches sum to it. (Event
counts and directly-follows counts, still available, measure different
things and disagree with each other wherever the model has concurrency or
a silently skipped branch.) The view reports how much of the log the
model explains alongside them. See
[`docs/metrics.md` §9](docs/metrics.md).

Deployed on Streamlit Community Cloud at
**https://pm4py-ucm.streamlit.app/** (`streamlit_app.py`, the
deployment's main file, is a shim that runs V5, so that URL always
tracks the latest code; the earlier V4, V3 and V1 apps live in git history).

**https://pm4py-ucm-scenarios.streamlit.app/** deliberately keeps
serving the **frozen V2 app** ([`web/streamlit_app_v2.py`](web/streamlit_app_v2.py),
model + scenarios) — it is referenced by a paper under review and
must stay as published.

The **Dashboards** view — added in V4, and in V5 measured over the same
filtered/renamed log as every other view — builds widgets
from a metric catalog over your log: filters (including a date range),
segmentation, targets with a **scorecard** that drills into the segments
that breached them, and a **ƒ custom-formula language**. A widget is drawn
as a KPI, gauge, histogram, box plot, bar, line, pie/donut or heatmap
table — whichever the metric and segmentation actually support — and the
grid is drag-reordered and drag-resized. A log can hold several **named
dashboards**, exported one at a time or **all in one** self-contained
interactive HTML file (or as a multi-section session report). See
[`docs/dashboards.md`](docs/dashboards.md) and
[`demo/dashboards_tutorial.ipynb`](demo/dashboards_tutorial.ipynb).

![Dashboards view](web/PM4Py-UCM-Dashboard.png)

**Save, share & resume.** A configured session — the log reference, CSV
mapping, renaming, filters, performers, overlays, decomposition, family and
scenario settings, the open view, **and your dashboards** — round-trips through
a project file, so you can put an analysis down and pick it back up, or hand it
to a colleague. Save from the sidebar's **Project** group as a small
**settings file** (`.ucmproj.json`, configuration only — no event data) or a
self-contained **project bundle** (`.ucmproj.zip`, configuration + the log);
resume either from the **log-source** area. Only *inputs* are stored — the
model, scenarios, family and reports recompute on load. See
[`docs/sessions.md`](docs/sessions.md).

**Export the analysis as runnable Python.** The same **Project** group emits the
current session — in one download — as both a plain-Python **script** (`.py`, with
a CLI entry point) and a **Jupyter notebook** (`.ipynb`), reproducing it over the
public `pm4py_ucm` API: log loading, the pre-mining rename + filters, mining,
decomposition, performers, the performance overlay (with the heat-map on the
exported images, each written as both `.png` and vector `.svg`), and optionally
the scenario, model-family and **dashboards** pipelines. The notebook is a
personalised **tutorial** — each stage runs where it's defined and shows the
result inline (the mined model and family grid as **SVG**, the variants, the live
dashboards). Because a project stores only *inputs*, the output is a faithful,
deterministic replay — a way to graduate a GUI exploration into an automatable,
version-controllable pipeline. It is a template emitter, no LLM involved. See
[`docs/code_export.md`](docs/code_export.md).

Run locally with:

```bash
pip install -r web/requirements.txt
streamlit run web/streamlit_app_v5.py    # V5 (latest — deployed)
streamlit run web/streamlit_app_v2.py    # V2 (frozen scenarios app)
```

See [`web/README.md`](web/README.md) for the full feature walkthrough and
Streamlit Community Cloud deployment instructions.

## Why UCM alongside BPMN?

BPMN is excellent for procedural choreographies, but it forces a single
abstraction level: every flow object is a step in *the* process. UCM, by
contrast, is a *scenario* notation. Its elements (path nodes — start
points, responsibilities, OR/AND forks and joins, stubs, timers, …) are
laid over a backdrop of **components**, which lets a single map describe a
behaviour that crosses architectural boundaries. This makes UCM a natural
target when the discovered process tree describes a workflow that spans
multiple services or organisational units, or when the goal is requirements
engineering rather than execution. See ITU-T Recommendation Z.151 for the
full notation reference.

## Installation

```bash
pip install pm4py-ucm           # core package + graphviz Python bindings
pip install pm4py-ucm[pm4py]    # also install pm4py for discovery
pip install pm4py-ucm[viz]      # add matplotlib for inline notebook display
pip install pm4py-ucm[dev]      # everything (pytest, pm4py, matplotlib)
```

The graphviz **system binary** must be on `PATH` for rendering (the
`graphviz` Python wheel only provides bindings):

```bash
# Debian / Ubuntu
sudo apt-get install graphviz
# macOS
brew install graphviz
# Windows
choco install graphviz
```

## Quick tour

### Build a UCM by hand

```python
from pm4py_ucm import UCM, write_ucm

ucm = UCM(name="OnlineShop")
m = ucm.add_map(name="ShoppingFlow")

start = m.add_node(UCM.StartPoint(name="start"))
login = m.add_node(UCM.RespRef(resp_def=ucm.get_or_add_responsibility("Login")))
fork  = m.add_node(UCM.OrFork(name="choose"))
search = m.add_node(UCM.RespRef(resp_def=ucm.get_or_add_responsibility("Search")))
browse = m.add_node(UCM.RespRef(resp_def=ucm.get_or_add_responsibility("Browse")))
join   = m.add_node(UCM.OrJoin())
checkout = m.add_node(UCM.RespRef(resp_def=ucm.get_or_add_responsibility("Checkout")))
end    = m.add_node(UCM.EndPoint(name="end"))

m.add_connection(start, login)
m.add_connection(login, fork)
m.add_connection(fork, search, condition="search")    # label on the OR branch
m.add_connection(fork, browse, condition="browse")    # label on the OR branch
m.add_connection(search, join)
m.add_connection(browse, join)
m.add_connection(join, checkout)
m.add_connection(checkout, end)

write_ucm(ucm, "online_shop.jucm")  # open in jUCMNav
```

### Mine a UCM from an event log

```python
import pm4py
import pm4py_ucm

log = pm4py.read_xes("log.xes")
ucm = pm4py_ucm.discover_ucm_inductive(log)
pm4py_ucm.view_ucm(ucm)                                # UCM notation
pm4py_ucm.save_vis_ucm(ucm, "diagram_ucm.png")         # UCM notation
pm4py_ucm.save_vis_ucm(ucm, "diagram_bpmn.png",        # BPMN notation
                       style="bpmn")
pm4py_ucm.write_ucm(ucm, "log.jucm")
```

`discover_ucm_inductive` is a thin wrapper around
`pm4py.discover_process_tree_inductive` followed by the bundled
process-tree → UCM converter, so all of PM4Py's tuning parameters for the
inductive miner are available via the `parameters` dict.

The PNG renderer supports two visual styles:

* **`style="ucm"` (default)** — Z.151 / jUCMNav notation: filled circle
  for the start point, perpendicular bar for the end point, `×` glyph
  with the responsibility name underneath, thick perpendicular bar for
  AND-fork / AND-join (synchronisation bars), small filled dot for
  OR-fork / OR-join, diamond reserved for stubs.
* **`style="bpmn"`** — BPMN-friendly look: activity boxes for
  responsibilities, gateway diamonds with `X` / `+` markers for
  XOR / AND gateways, thin-border start circle and thick-border end
  circle (the canonical BPMN end event).

Both styles preserve the swim-lane layout (one rectangle per
component, never overlapping unless nested) and wrap long
responsibility names onto two or three lines so the diagram stays
compact.

### `.jucm` layout matches the PNG layout

When writing a `.jucm` file, pm4py-ucm uses graphviz's `dot` engine to
compute the coordinates — exactly the same engine that drives the PNG
renderer. Open the resulting file in jUCMNav and you'll see the same
arrangement of nodes and component rectangles as in the rendered PNG.

If the graphviz binary isn't on `PATH`, the exporter falls back
silently to the bundled Sugiyama-style layouter so writing still
works; the visual result will be a layered drawing rather than a
graphviz one. Force the built-in layouter explicitly with:

```python
pm4py_ucm.write_ucm(ucm, "log.jucm",
                    parameters={"layout_engine": "builtin"})
```

### Mine performers too — surface them as URN components

If the log records who performed each activity (typically the
`org:resource` or `org:role` event attribute), pm4py-ucm can mine the
activity→performer mapping and use it to populate URN components
automatically: each unique performer becomes a `ComponentElement`, each
`Responsibility` is linked to its performer, and on every map the
`RespRef` symbol for that activity is drawn inside the component's
rectangle.

```python
import pm4py
import pm4py_ucm

log = pm4py.read_xes("log.xes")

# One-shot: mine + bind in a single call.
ucm = pm4py_ucm.discover_ucm_inductive(log, parameters={
    "resource_attribute": ["org:role", "org:resource"],  # priority list
})

# Or, the explicit three-step flow with more control.
performers = pm4py_ucm.discover_resources(
    log,
    attribute_priority=["org:role", "org:resource"],
    strategy="mode",        # or "first", "all", "unbound"
    min_support=0.0,        # default — pick the modal performer
                            # even when no single one owns a majority
)
ucm = pm4py_ucm.discover_ucm_inductive(log)
pm4py_ucm.bind_performers(ucm, performers)

pm4py_ucm.write_ucm(ucm, "log.jucm")
```

#### Component discovery vs activity binding

There are two related questions to answer when mining resources:

* **Which activities have a clearly-identified performer?** This is what
  `discover_resources` answers — it returns one performer per activity
  using the configured aggregation strategy (`mode` by default). An
  activity with many performers spread roughly equally gets bound to
  the modal one; an activity with no `org:resource` annotation at all
  is omitted.
* **Which actors exist in the log?** This is what `discover_components`
  answers — it returns the full *vocabulary* of every distinct
  performer value that appears anywhere in the log, sorted. The
  high-level `discover_ucm_inductive` calls both, so every distinct
  actor becomes a URN `ComponentElement` even when no single
  responsibility is cleanly bound to it. The unbound components show
  up in the URN tree but not as rectangles on the map.

This matters for logs with a dispersed resource pool — e.g. the BPI
Road Traffic Fines log has 148 distinct `org:resource` values where the
modal performer of *Create Fine* owns only 5.7% of events. Without these
two adjustments (modal binding without majority support, and full
component-vocabulary discovery), only one of the 148 actors would
appear in the resulting URN spec.

In the rendered diagram and the exported `.jucm`, each activity now
appears inside the rectangle of the team that owns it. See
[Definitions vs references](#definitions-vs-references) below for the
data-model story.

### Read an existing jUCMNav file

```python
from pm4py_ucm import read_ucm

ucm = read_ucm("requirements.jucm")
print(ucm)                          # → UCM(name='…', maps=…, responsibilities=…)
for n in ucm.maps[0].nodes:
    print(n)
```

## Process-tree → UCM mapping

The converter implements the following correspondences between PM4Py's
process tree operators and UCM constructs. UCM has no native loop, so loops
are encoded as an OR-fork/OR-join pair guarded by `[redo]` / `[exit]`
conditions — the canonical idiom in jUCMNav.

| Process tree                            | UCM construct                                                                 |
|-----------------------------------------|-------------------------------------------------------------------------------|
| Activity leaf with label `A`            | `RespRef` referencing a `Responsibility` named `A`                            |
| Silent (τ) leaf                         | A direct `NodeConnection` with no responsibility                              |
| `→ (sequence)` of children              | Children chained with `EmptyPoint` connectors (collapsed by simplifier)        |
| `× (xor)` choice                        | `OrFork` → branches → `OrJoin`                                                |
| `+ (parallel)`                          | `AndFork` → branches → `AndJoin`                                              |
| `o (interleaving)`                      | Treated as `+ (parallel)`                                                     |
| `∨ (or)`                                | Treated as `× (xor)`                                                          |
| `↻ (loop, do, redo)`                    | `OrJoin` → *do* → `OrFork` with `[redo]` back-edge and `[exit]` forward edge   |

After conversion, an `EmptyPoint` simplification pass collapses chains of
unnamed degree-2 connectors so that the resulting map renders compactly.

## Hierarchical decomposition

For complex process trees, a single flat UCM map quickly becomes visually
overwhelming. The optional `decomposition=` keyword on
`discover_ucm_inductive` and `convert_to_ucm` splits the result into a
**root map plus plug-in (sub-)maps connected by Stubs**. The same
`PluginBinding` machinery the package already uses for hand-built models
ties everything together — every round-trip stays byte-stable through the
exporter and importer.

```python
import pm4py
import pm4py_ucm

log = pm4py.read_xes("running-example.xes")

# Default: one flat map (current behaviour, byte-stable with old exports)
flat = pm4py_ucm.discover_ucm_inductive(log)
assert len(flat.maps) == 1

# Hierarchical: a root map + one plug-in per "phase" / branch / loop body
hier = pm4py_ucm.discover_ucm_inductive(log, decomposition="auto")
assert len(hier.maps) >= 1

pm4py_ucm.view_ucm(hier)                   # all maps stacked in one PNG
pm4py_ucm.view_ucm(hier, map="loop_Test")  # just one plug-in
pm4py_ucm.write_ucm(hier, "out.jucm")      # opens in jUCMNav as root+plug-ins
```

The `decomposition` argument accepts:

| Value          | Effect                                                                                 |
|----------------|----------------------------------------------------------------------------------------|
| `None` / `"off"` | No decomposition. Output byte-stable with pre-decomposition exports.                |
| `"auto"`       | All four boundary rules on; `max_leaves_per_map` and `min_leaves_to_decompose` **fitted to the tree shape** at conversion time — see `suggest_decomposition` (cap ≈ 1.5·√N, floor ≈ 0.15·N in the leaf count N). |
| `"aggressive"` | Fixed preset: same boundary rules, `max_leaves_per_map=10`.                            |
| `dict`         | Any subset of the keys below; unspecified keys take the `"auto"` defaults. A size dimension may be the string `"auto"` to fit just that one to the shape. |

`pm4py_ucm.suggest_decomposition(process_tree)` returns the shape-fitted dict directly if you want to inspect or tweak it.

Configurable keys:

| Key                         | Default | Meaning                                                                                                                                  |
|-----------------------------|---------|------------------------------------------------------------------------------------------------------------------------------------------|
| `on_root_sequence`          | `True`  | Each child of a top-level `→` becomes a plug-in. Root map reads as a chain of phase stubs.                                                |
| `on_parallel`               | `True`  | Each `+` branch becomes a plug-in. AND-fork/join vertical-expansion cost is replaced by a single stub per branch.                          |
| `on_alternative`            | `True`  | Each `×` (XOR) / `∨` (OR) branch becomes a plug-in. OR-fork/join stays on the parent map; alternative bodies move into per-branch plug-ins. |
| `on_loop`                   | `True`  | Each `*` operator's entire expansion becomes a plug-in. Parent map reads as forward flow with one stub for the iteration. A loop at the *root* of the tree is wrapped in a synthetic sequence so the root map gets a single loop stub. |
| `max_leaves_per_map`        | `20`    | Hard cap; over-sized maps recursively force-cut the largest operator-subtree until the cap is met.                                         |
| `min_leaves_to_decompose`   | `4`     | Floor — subtrees smaller than this stay inlined regardless of rules.                                                                       |
| `balance_ratio`             | `0.2`   | Sibling share threshold under `→` and `+`. A child needs at least this fraction of the parent's leaves to be pulled out independently.     |

Unknown keys raise `ValueError`.

When the UCM has multiple maps, `view_ucm` and `save_vis_ucm` compose
every map vertically into a single PNG (`Pillow` does the stacking).
Each panel has a title strip and adjacent panels are separated by a thin
horizontal rule. Bound stubs gain a `→ <plug-in name>` external label so
the reader can follow each stub to its plug-in map.

## Scenario synthesis

The `discover_scenarios` pipeline turns an event log into an
*executable* UCM: a `.jucm` carrying one URN `ScenarioDef` per
behavioural variant discovered in the log, with typed variables,
per-loop integer counters, and mutually-exclusive OR-fork conditions
that let jUCMNav step through each scenario deterministically.

```python
import pm4py
import pm4py_ucm

log = pm4py.read_xes("log.xes")

# Variant-driven (default) — lossless replay of every observed variant
ucm, clustering = pm4py_ucm.discover_scenarios(log)
pm4py_ucm.write_ucm(ucm, "log.jucm")
pm4py_ucm.write_variants_report(clustering, "variants.csv")
pm4py_ucm.write_case_variant_map(clustering, "case_variant_map.csv")

# Data-driven — mine per-fork decision trees over case attributes,
# emit conditions like `Broker == Spot_Health_Insurance && Claim_Value <= 1417646`
ucm_dd, _ = pm4py_ucm.discover_scenarios(
    log, condition_strategy="data-driven",
    decision_tree_max_depth=3,
)
group = ucm_dd.scenario_groups[0]
pm4py_ucm.write_ucm(ucm_dd, "log.data_driven.jucm")
pm4py_ucm.write_condition_mining_report(group, "condition_mining.csv")
```

### What the synthesizer populates

- **`EnumerationType` `VariantId`** with values `[v1, v2, …]`
  (variant-driven only), plus one `EnumerationType` per case-constant
  string attribute the log carries (data-driven only).
- **`Variable`s** — a `variant_id` enum (variant-driven) or one variable
  per mined case attribute (data-driven), plus one `integer` per loop
  operator in the discovered tree (contextually named e.g.
  `Loop_AnalyzeClaim`).
- **One `ScenarioDef` per variant**, each with an `Init` per variable
  (variant-driven initialises `variant_id`; data-driven initialises
  each attribute to a representative value for its variant — mode for
  enum/bool, scaled median for integer), plus a per-loop counter init
  capped at `max_loop_iterations` (default 2), plus `ScenarioStartPoint`
  / `ScenarioEndPoint` refs. Names carry a short discriminator
  (`v3_TwoCloseAssessmen`, `v8_QuickAssessment`); descriptions start
  with a plain-English `Intent:` line.
- **Arc conditions on every non-loop OR-fork** — variant-driven writes
  `variant_id == v_i` disjunctions (with an inside-loop variant that
  combines the disjunction with counter thresholds); data-driven writes
  mined boolean expressions over case attributes.
- **A `LoopEntryGuard` OR-fork** per loop, spliced between the loop's
  upstream arc and its `LoopJoin`, with mutually-exclusive `counter > 0`
  / `counter <= 0` conditions. This restores the semantics
  "counter = number of body executions" — including zero. When a
  loop's post-loop continuation is a `Stub`, an `OrJoin` is spliced
  before the stub so its plug-in binding stays complete.

### Before running scenarios in jUCMNav: check the hit-count ceiling

jUCMNav declares an infinite loop as soon as a **single element** has
been entered as many times as its *maximum hit count* preference allows
(*Preferences → jUCMNav → Scenario Traversal*). That ceiling is a
setting, not a property of your model. Set below what a model
legitimately needs, jUCMNav abandons the offending visit, its AND-joins
then starve, and the scenario never reaches its end point — producing a
Problems view full of blocked joins and unreached end points for a model
that is perfectly correct.

A model whose discovered tree nests loops around parallel branches
enters some elements once per iteration of *every* enclosing loop, so it
can need a far higher ceiling than a flat one. Across the 28 models of
our evaluation most need 2–6; one, a depth-3 loop nest, needs 10 — and
against a preference set to exactly 10 it reported eight errors that
were entirely artefacts of the setting.

jUCMNav's own default is 1000, which is ample. If yours has been
lowered, raise it before concluding that a generated model is broken.
To find what a specific model needs:

```python
from pm4py_ucm.algo.scenario_traversal import required_max_hit_count

need, scenario, element = required_max_hit_count(ucm)
print(f"set jUCMNav's maximum hit count to at least {need} "
      f"(driven by {element} in {scenario})")
```

The same module executes every scenario offline under jUCMNav's
traversal semantics, so a model can be checked before it is opened in
the tool at all — it reports blocked AND-joins, unreached end points,
and forks where no branch (or more than one) is enabled:

```python
from pm4py_ucm.algo.scenario_traversal import check_traversal

for problem in check_traversal(ucm):
    print(problem)
```

### Concurrency-aware variants

Two traces that differ only in the interleaving order of activities
inside a parallel block share the same **choice signature** and
therefore the same variant. `X → (Y ∥ Z) → W` traces `X-Y-Z-W` and
`X-Z-Y-W` cluster as one; sequence-variant analysis splits them. The
`compression_ratio` (concurrency-aware / sequence-variant count) on
`ClaimsPaymentLog` is 0.146, meaning naive clustering over-counts by
~7×.

Loop iteration counts are coarsened to `{0, 1, ≥2}` by default to keep
the variant count small; pass `coarsen_loops=False` to distinguish
every iteration count.

### Reading a variant expression

Each variant is summarised as a compact **partial-order expression**
(shown in `variants.csv`, the Scenarios tab, and scenario
descriptions). It is a per-variant *projection* of the discovered
process tree — it shows what this behavioural variant actually did, not
the whole model:

| Syntax | Source | Meaning |
|--------|--------|---------|
| `A` | activity leaf | activity `A` was executed here — no choice, no loop |
| `X -> Y` | sequence | `X`, then `Y` |
| `[A]` | XOR / OR | a **choice** point; branch `A` is the one this variant took (another variant may show `[B]` here) |
| `(X \|\| Y)` | AND (parallel) | `X` and `Y` ran concurrently — any interleaving is the same variant |
| `A^0` / `A^1` / `A^>=2` | loop | loop body `A` ran zero / exactly once / two-or-more times (the coarsened `{0, 1, ≥2}` bucket) |

Two conventions worth knowing: a parallel branch this variant **skipped**
shows as `[tau]` (the silent choice was taken), so `(E || [tau])` means
"`E`, in parallel with an optional branch that was skipped here"; and a
parallel of a single activity with nothing (`A || tau`) simplifies to
just `A`.

### Two condition-encoding strategies

| Strategy       | Arc conditions                                                                | Trade-off                                                                                                     |
|----------------|-------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|
| `variant`      | `variant_id == v_i` disjunctions per branch                                   | **Lossless** — replaying scenario `v_i` reproduces `v_i`'s choice signature exactly. Doesn't *explain* choices. |
| `data-driven`  | Boolean expressions over case attributes, mined per-fork by decision trees    | Business-readable rules on every fork; requires case-constant attributes; **abandons with a warning** otherwise. |

Inside-loop OR-forks (XORs sitting inside a loop body): variant-driven
combines `variant_id` with the enclosing counter to distribute branches
across iterations. Data-driven falls back, only for inside-loop forks,
to a deterministic `true`/`false` split — case attributes are static per
case and can't disambiguate per-iteration choices.

### Reports

Three CSVs alongside the `.jucm`:

- **`variants.csv`** — one row per variant with frequency, sequence-
  variant count, linearization count, partial-order expression, and a
  truncated case-ID list. Trailing rows for `noise` and `totals`
  (fitness + compression).
- **`case_variant_map.csv`** — one row per case, mapping case ID to
  variant ID (or `noise` for non-conforming cases).
- **`condition_mining.csv`** — data-driven mode only. One row per
  `(OR-fork, branch)` with accuracy, sample size, feature set,
  `skipped_reason` (`inside_loop`, `no_labelled_cases`), and the
  post-minimisation expression emitted on the arc.

### Interaction with decomposition

`discover_scenarios` accepts the same `decomposition=` argument as
`discover_ucm_inductive` and honours it fully: OR-forks that land in
plug-in maps receive the same conditions they would in the flat case,
and loops pushed into plug-in maps get their counter machinery
(LoopEntryGuard, decrement responsibility) spliced into the correct
map. Each UCM `OrFork` / `OrJoin` / `LoopFork` / `LoopJoin` carries a
stable id linking it back to the tree node it came from, so
correlation survives arbitrary decomposition boundaries.

### Learning path

- [`demo/scenario_synthesis_tutorial.ipynb`](demo/scenario_synthesis_tutorial.ipynb)
  — a small synthetic example per section, then a capstone on
  `ClaimsPaymentLog` (24 variants, compression 0.146) in both the
  variant-driven and data-driven / decision-mining encodings.
- The Scenarios view in
  [`web/streamlit_app_v5.py`](web/streamlit_app_v5.py) — no code needed.

## Model families (attribute-partitioned discovery)

Many logs mix cases that follow *different processes* — a cancer-care
log contains distinct pathways per cancer type, a claims log may route
work differently per country. `discover_ucm_family` partitions the log
by the values of **one or two case-level attributes** and mines one
model per combination; the family can then be exported as separate
models, rendered side by side, or assembled into a single
**overarching model** where UCM's own variability constructs carry the
family: **dynamic stubs are variation points, plug-in maps are
variants, and scenario strategies are configurations**.

```python
family = pm4py_ucm.discover_ucm_family(
    log, ["cancer_type", "age"],       # 1–2 attributes; numerics are binned
    decomposition="auto", min_cases=20,
)

pm4py_ucm.write_ucm_family(family, "family.zip")        # one .jucm per cell
pm4py_ucm.save_vis_ucm_family(family, "grid.png")       # stack / matrix view

umbrella = pm4py_ucm.assemble_ucm_family(family, mode="umbrella")
pm4py_ucm.write_ucm(umbrella, "family_umbrella.jucm")   # opens in jUCMNav
```

The umbrella's root map is the **shared skeleton** of the cell
processes (computed by anti-unifying the per-cell process trees), with
a dynamic stub only where behaviour actually diverges; each stub's
plug-ins are guarded by preconditions over the attributes
(`cancer_type == Breast && age_group == _40_59`). Behaviourally
identical variants share one plug-in with a domain-factored condition;
**resource variation counts as variation** (the same activity done by
different actors becomes a variation point, each variant drawn inside
its actor); and by default each combination gets **executable path
scenarios** — one per behavioural variant of its sub-log, with
`family_variant` branch conditions and loop counters, so jUCMNav's
traversal walks genuinely different paths per strategy.

Full documentation — partitioning policy, skeleton merge rules,
dedup/conditions, path scenarios, grid resolution, value filtering —
in [`docs/model_families.md`](docs/model_families.md). The web app's
**Family** view exposes all of it interactively, including a
pre-mining coverage heatmap and per-attribute value filters.

### Family statistics reports

Compare the family's processes quantitatively — and hand the result
to collaborators as **one self-contained interactive HTML file**:

```python
stats = pm4py_ucm.compute_family_stats(family)     # needs family.log_df
stats.process_frame()                              # pandas: one row per cell
pm4py_ucm.write_family_report(family, "report.html", stats=stats)
```

`compute_family_stats` yields four statistics levels per family
member: **process** (cases, events per case, case-duration
min/mean/median/max/**total**/std/P90/P95, **rework rate**, start/end
activity distributions, behavioural variant counts, replay fitness),
**activity** (frequency, relative frequency, case coverage, **repeat
frequency**, sojourn time since the previous event — available even for
single-timestamp logs — and service-time min/mean/median/max/std/P90/
P95/total on interval logs), **edge** (directly-follows pairs with
traversal frequency, **case frequency**, relative frequency, and
waiting-time aggregates), and **choice** — OR-fork branch counts
*aligned across the family* through the shared skeleton, so the same
decision point is one comparable row for every combination. Every
metric is defined in [`docs/metrics.md`](docs/metrics.md).

`write_family_report` renders it all into a zero-dependency HTML
report that opens offline in any browser: sortable heat-mapped
ranking tables, a pair-comparison view (any two members side by side
with their **model images embedded**, delta cards, activity Δ/ratio
tables), 100% stacked branch-share bars per choice, and a model
gallery. Every share and time is shown next to its `n`, and metrics
the log cannot support are omitted rather than faked. The web app's
**Compare** view serves the same statistics interactively and
offers the report as a download.

## Performance overlays

Frequencies and times computed from the log, displayed on activities
and edges and exported as jUCMNav metadata:

```python
pm4py_ucm.annotate_performance(
    ucm, log,
    node_metrics=["frequency", "median_time"],
    edge_metrics=["percentage", "mean_time"],
)
pm4py_ucm.save_vis_ucm(ucm, "annotated.png")   # small gray overlay text
pm4py_ucm.write_ucm(ucm, "annotated.jucm")     # per-metric metadata lines
```

Activity metrics: `frequency`, `case_coverage`, `relative_frequency`,
`repeat_frequency` (rework), and (for interval logs with a
`start_timestamp` column) `mean/median/min/max/std/p90/p95/total_time`
service times, plus the `sojourn_*` variants on any timestamped log.
Edge metrics: directly-follows `frequency`, `case_frequency`,
`relative_frequency`, OR-fork branch `percentage`, and the
`mean/median/min/max/std/p90/p95/total_time` waiting times — attributed
via activity-to-activity *segments* that walk through bends, joins,
forks, and static stubs (so decomposed models are covered too). The
sidebar's ≤2 selections control what is drawn on the diagram, but
**every** available metric is exported as its own `perf_<metric>`
metadata line for jUCMNav's properties view; the family assemblies
annotate the shared skeleton from the whole log and each variant
plug-in from its own sub-log. Overlay walkthrough in
[`docs/model_families.md`](docs/model_families.md#2-performance-overlays).

Beyond the text sub-line, an optional **heat-map** colours and thickens
activities and edges by the *first* chosen metric of each layer — across the
**Model, Family and Compare** views alike (a **time** metric reads red, any
other blue; darker/thicker = higher). Its **scale** control offers three modes:
**Local (per map)** (each diagram to its own min/max), **Per family member
(across its maps)** (each Family/Compare member against its own range, pooled
over its decomposed maps — the whole model in the Model view), and **Global
(across family members)** (every member against **one shared range**, so a
colour is comparable across members). It is a render-time overlay of the same
`perf_<metric>` values — the model and `.jucm` are unchanged — exposed by
`model_to_svg(ucm, style, heatmap=True, node_metric=…, edge_metric=…,
heatmap_global=…, node_span=…, edge_span=…)`; `classic.heat_span(models, …)`
computes the shared cross-member span.

**📏 [`docs/metrics.md`](docs/metrics.md) is the readable, complete list
of every metric definition** — activity, edge, process and choice —
with units, timestamp semantics, aggregation rules, and the deliberate
edge-case decisions (negative waiting on overlapping intervals, tie
handling, and the working-calendar caveat: all times are raw wall-clock,
with no weekends/holidays subtracted). It is the semantic contract the
metric-validation suite enforces against pm4py.

## Module layout

```
pm4py_ucm/
├── api.py                                 # high-level read_/write_/discover_/view_
├── objects/ucm/
│   ├── obj.py                             # UCM object model (URN metamodel)
│   ├── conversion/from_process_tree.py    # PM4Py process tree → UCM
│   ├── conversion/decomposition.py        # hierarchical decomposition rules + presets
│   ├── exporter/variants/jucm.py          # UCM → jUCMNav .jucm (XMI 2.0)
│   ├── importer/variants/jucm.py          # jUCMNav .jucm → UCM
│   └── layout/layouter.py                 # auto-layout for jUCMNav graphical view
├── algo/
│   ├── performance.py                     # frequency/time overlays on activities + edges
│   └── discovery/
│       ├── ucm/
│       │   ├── algorithm.py               # discovery dispatcher (mirrors BPMN)
│       │   └── variants/inductive.py      # inductive-miner-based discovery
│       ├── variants/                      # concurrency-aware variant clustering
│       │   ├── choice_signature.py        # replay algorithm + signature canonicalisation
│       │   └── clustering.py              # per-variant clustering + fitness / compression
│       ├── scenarios/                     # scenario synthesis on top of a UCM + clustering
│       │   ├── synthesis.py               # variables, ScenarioDefs, LoopEntryGuard, conditions
│       │   ├── decision_mining.py         # data-driven strategy: sklearn tree → jUCMNav expr
│       │   ├── expression_minimizer.py    # boolean simplifier for mined expressions
│       │   └── reports.py                 # variants.csv / case_variant_map.csv / condition_mining.csv
│       └── families/                      # attribute-partitioned model families
│           ├── partition.py               # case-attribute detection + log partitioning
│           ├── family.py                  # ModelFamily container + zip/dir export
│           ├── algorithm.py               # per-cell discovery driver
│           ├── assembly.py                # combined + skeleton-umbrella assembly
│           ├── scenarios.py               # per-cell path scenarios on the umbrella
│           ├── stats.py                   # FamilyStats: process/activity/choice comparison
│           └── report.py                  # self-contained interactive HTML report
└── visualization/ucm/
    ├── visualizer.py                      # apply / view / save (mirrors BPMN)
    ├── stacked.py                         # vertical multi-map composition
    ├── family_grid.py                     # family stack/matrix rendering (adaptive DPI)
    └── variants/classic.py                # graphviz-based renderer
```

The object model in `objects/ucm/obj.py` mirrors the [jUCMNav EMF
metamodel][jucmnav-meta] (`urn`, `urncore`, `ucm.map`) closely enough to
emit XMI files that load directly in jUCMNav.

[jucmnav-meta]: https://github.com/JUCMNAV/projetseg-update/tree/master/seg.jUCMNav/src/seg/jUCMNav/emf

## Definitions vs references

For the full Python object model in one picture, see
[`docs/ucm_class_diagram.svg`](docs/ucm_class_diagram.svg) (vector,
paper-ready) or the [PNG preview](docs/ucm_class_diagram.png). The
[PlantUML source](docs/ucm_class_diagram.puml) can be re-rendered or
extended for figures in academic papers.

UCM keeps a sharp distinction between a *definition* (a reusable named
concept declared once at the URN level) and each visual *reference* to it
on a map. The object model surfaces this distinction explicitly:

| Definition (one) | Reference (many) | Where reference lives |
|---|---|---|
| `UCM.Responsibility` (an activity) | `UCM.RespRef` (the "✕" symbol) | inside a map's `nodes` list |
| `UCM.ComponentElement` (an actor / team / role / system) | `UCM.ComponentRef` (the labelled rectangle) | inside a map's `cont_refs` list |

A definition lives on the URN container (`ucm.responsibilities`,
`ucm.components`). Each *visual occurrence* on a diagram is a separate
reference object that points back to the definition via `resp_def` (for
`RespRef`) or `cont_def` (for `ComponentRef`). Many references may share
the same definition — that is exactly what lets the same activity or the
same actor appear in multiple places without being declared twice.

A third link — `Responsibility.performer` — runs *between definitions*:
it expresses the semantic fact that a given activity is performed by a
given actor/team. This is a logical binding, independent of layout. The
visual binding (`RespRef.cont_ref → ComponentRef`) is derived from it
whenever a map is built or `UCM.bind_performers()` is called.

```text
─ URN level ──────────────────────────────────────────
 Responsibility "Login" ──performer──> ComponentElement "AuthService"
            ▲                                  ▲
            │ resp_def                         │ cont_def
─ Map level ──────────────────────────────────────────
   RespRef #5  ─────cont_ref─────────>  ComponentRef #12
   (drawn as ✕)                         (drawn as a rectangle)
```

Build them through the helpers:

```python
from pm4py_ucm import UCM

ucm = UCM(name="Example")
m   = ucm.add_map(name="MainMap")

# --- DEFINITIONS (one per concept, declared on the URN container) ---
login_def = ucm.get_or_add_responsibility("Login")          # Responsibility
actor_def = ucm.get_or_add_component(                       # ComponentElement
    "User", kind=UCM.ComponentElement.Kind.ACTOR)

# --- REFERENCES (many per definition, drawn on the map) -------------
login_node = m.add_node(UCM.RespRef(resp_def=login_def))    # RespRef
actor_box  = m.add_component_ref(actor_def, width=200, height=120)

# A path node may declare which component reference visually contains it:
login_node.cont_ref = actor_box
```

In the exported `.jucm` you'll see this distinction reflected as the
bidirectional links jUCMNav uses internally:

```xml
<responsibilities name="Login" id="35" respRefs="36"/>
<components       name="User"  id="45" contRefs="46" kind="Actor"/>
…
<nodes   xsi:type="ucm.map:RespRef" id="36" respDef="35" contRef="46" …/>
<contRefs xsi:type="ucm.map:ComponentRef" id="46" contDef="45" nodes="36" …/>
```

The `respRefs`/`contRefs` attributes on a definition list back-references
to every occurrence of it; `respDef`/`contDef` on the reference point
forward to the definition. The exporter computes the back-references
automatically — you only need to set the forward links.

## Compatibility with jUCMNav

The exporter produces files in the modern jUCMNav format (matches output
of jUCMNav 5.5 and later):

* Root element `<urn:URNspec>` declares four namespaces — `xmi`, `xsi`,
  `urn` (`http:///urn.ecore`), and `ucm.map` (`http:///ucm/map.ecore`).
  The `urncore` and `grl` packages do not need declarations because
  their concepts use unqualified element names inside the URN
  containment tree.
* URN-level metadata (`urnVersion="1.27"`, `specVersion="4"`, `name`,
  `author`, `created`, `modified`, `nextGlobalID`) lives on the root
  as **attributes**, not child elements.
* Children of `<urn:URNspec>` appear in the canonical order
  **ucmspec → grlspec → urndef**. Children of `<urndef>` appear in the
  order **responsibilities → specDiagrams → components**. Children of a
  `<specDiagrams>` UCMmap appear as **nodes → contRefs → connections**.
* Connections are **anonymous** — `<connections>` carries no `id`
  attribute. Endpoints use integer node IDs (`source="18" target="19"`).
* Nodes refer to their connections via XPath fragments
  (`succ="//@urndef/@specDiagrams.0/@connections.7"`), the only style
  available because connections lack IDs.
* `<condition>` elements distinguish a human-readable `label` (e.g.
  `"TrueBranch"`) from the logical `expression` (default `"true"`).
* `nextGlobalID` is exactly `max(all IDs) + 1` — the integer ID jUCMNav
  would assign to the next newly-created element.
* An auto-layouter places nodes on a layered left-to-right grid before
  export so the diagram is immediately readable in jUCMNav's editor.

## Documentation

The full API reference — every public helper and every module, rendered
from the in-source docstrings — is published to **GitHub Pages**:

> **<https://processmining-uottawa.github.io/pm4py-ucm/>**

It is built with [pdoc](https://pdoc.dev) and redeployed automatically on
every push to `main` ([`.github/workflows/docs.yml`](.github/workflows/docs.yml));
nothing generated is committed. To reproduce the deployed site locally:

```bash
pip install -e .[docs]
pdoc pm4py_ucm pm4py_ucm.algo pm4py_ucm.api pm4py_ucm.objects \
     pm4py_ucm.util pm4py_ucm.visualization -o docs/api
# then open docs/api/index.html
```

The landing page is the curated public API (the names in
`pm4py_ucm.__all__`); naming the subpackages makes pdoc expand each into
its full module tree, which the sidebar links. `pdoc pm4py_ucm` on its own
documents only the public-API page, because `__all__` lists just the
public helpers. Narrative guides live alongside the code in
[`docs/`](docs/): [`metrics.md`](docs/metrics.md),
[`dashboards.md`](docs/dashboards.md),
[`model_families.md`](docs/model_families.md).

## Testing

```bash
pip install -e .[dev]
python -m unittest discover -s tests -v
```

See [`tests/README.md`](tests/README.md) for a per-file map of the suite
(purpose and test count for each of the 22 test modules).

The default test suite does **not** require PM4Py to be installed: the
process-tree → UCM converter accepts duck-typed trees (`operator.value`,
`children`, `label`), which the tests use to exercise every operator in
isolation. The `tests/test_export_import.py` suite verifies that round-trip
through the jUCMNav XMI back-end is byte-deterministic.

The performance and family-statistics metrics are validated in
`tests/test_metric_validation.py` against four independent oracles —
a hand-computed distinct-value fixture, algebraic invariants,
metamorphic transforms, and simulation ground truth — with an optional
differential check against pm4py's own DFG/duration functions. The
metrics they enforce are specified in [`docs/metrics.md`](docs/metrics.md).

### Security scan

A lightweight static scan runs locally — no repository code-scanning or
CI minutes required:

```bash
bandit -r pm4py_ucm web -ll     # medium+ severity; ships in .[dev]
```

The medium/high baseline is clean. The `.jucm` importer refuses DTDs
before parsing (`_forbid_dtd`) so untrusted `.jucm` input cannot trigger
XML entity-expansion ("billion laughs") — a zero-dependency alternative
to `defusedxml`. Event-log (XES) parsing is delegated to PM4Py.

## License

GPL-3.0-or-later, matching the upstream [PM4Py][pm4py-gpl] license.
The jUCMNav metamodel reproduced (in spirit) here is itself distributed
under EPL-2.0; this package only re-implements the metamodel in Python and
does not bundle any jUCMNav source.

[pm4py-gpl]: https://github.com/process-intelligence-solutions/pm4py/blob/release/LICENSE
