Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,35 @@ and each [release on GitHub](https://github.com/s4mstruthers/openprocess/release
uses it as its description. Write for the people using the app: what they will
notice, not how it was done. Versions before 0.7.0 were released as CPNpy.

## 0.11.2

- **Fixed: Describe log and Correlate failed with "No converter from
EventLog to pandas.DataFrame" whenever pandas was installed.** A converter
was registered under the class's defining module
(`pandas.core.frame.DataFrame`) and asked for by its public name
(`pandas.DataFrame`); the registry now resolves both to the class, in
either direction, and still imports nothing until a conversion is asked
for. The same fix covers pandas 3, which reports its classes under
`pandas` rather than `pandas.core.frame`.
- **What "reproducible" promises is written down**, in
[docs/workflows.md](docs/workflows.md#what-reproducible-promises): the
badge and `--check` mean fingerprints equal to the record's; three tiers
say how far that travels (same machine, same lockfile, another platform),
and what is and is not in the record.
- **docs/design.md: why OpenProcess is built the way it is.** The problem,
the claim, the three ideas and the decisions that follow from them, with
the open decisions (the `.cpnflow` name, what the 1.0 freeze covers).
- **A cross-check of the Inductive Miner against PM4Py**,
`tests/cross_check_inductive_miner.py`: both miners on the textbook logs
and 400 random ones, trees compared after sorting the commutative
operators, the IM guarantees checked on every log, and the precision of
both models wherever they differ. Baseline against PM4Py 2.7.23: the
guarantees hold on all 406 logs; 369 trees are identical; on 37 the
reference model is usually more precise, always for the same reason: after
a sequence cut, groups that are only ever skipped together are made
skippable separately. Not fixed yet; the script is there to show when it
is.

## 0.11.1

- **A click no longer flings a box across the canvas.** Clicking a box
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,10 @@ print(report.sound, report.findings)

[**docs/how-it-works.md**](docs/how-it-works.md) covers the architecture, the
CPN ML subset, binding search, timed nets, the state space, the file format
and the project layout.
and the project layout. [**docs/design.md**](docs/design.md) is the *why*:
the problem, the claim, the three ideas and the decisions that follow from
them. `tests/cross_check_inductive_miner.py` compares the built-in Inductive
Miner with PM4Py's on hundreds of logs and reports where they differ.

## Workflows

Expand Down
136 changes: 136 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Why OpenProcess is built the way it is

The reasoning behind the design, in one place, so that a decision can be
checked against it before it is made. The *what* is in the README; the
*how* is in [how-it-works.md](how-it-works.md) and
[workflows.md](workflows.md). This page is the *why*.

## The problem

A process-mining analysis, as it is usually done, cannot answer three
questions a reviewer, a supervisor or the analyst a year later will ask:

1. **What exactly ran?** The model came from a plug-in, the map from
another tool, the table from a notebook. Which implementation, which
version, which settings?
2. **How did it get there?** A fitness of 0.87 is a number. The replay
behind it, the cuts the miner chose, the places the α-algorithm derived
from which pairs: not shown anywhere.
3. **Can I get the same numbers again?** Settings, seeds and input files
live in someone's memory. Re-running means reconstructing.

The same gap shows up in teaching. A student sees the output of an
algorithm and is then asked, on an exam, to produce its derivation by hand.
The tool that could show the derivation shows only the result.

## The claim

**Code is mathematics, and an analysis should carry its own derivation.**
A proof in a paper shows its steps; a discovery algorithm is a procedure
whose model is its conclusion. A tool that gives only the conclusion is a
black box, however good the conclusion is. Everything in OpenProcess
follows from taking that seriously: what ran, how it got there, and how to
get the same numbers again are kept *with* the result, by the framework,
not by the discipline of the person using it.

## Three ideas, and what each one buys

1. **Every algorithm is a box, and every box is a plain Python function.**
The decorator reads the function's type hints and docstring; there is no
base class, no registration call, no plug-in descriptor. *Buys:* a
researcher's algorithm is on the canvas in the time it takes to add type
hints; the function still runs in a notebook or a test exactly as the
app runs it; and the Code tab can show the box's few lines and the
algorithm it calls, because the box *is* the code.
2. **Every analysis is a workflow file that can be re-run.** The `.cpnflow`
file records the boxes, settings and connections, plus fingerprints of
the inputs and the box code, every seed, the versions and the installed
packages. *Buys:* the badge, `--check` in CI, the experiment zip for a
paper, and the Runs tab; see "What reproducible promises" in
[workflows.md](workflows.md#what-reproducible-promises) for exactly
what is and is not promised.
3. **Every box shows how it got there.** A box reports notes, intermediate
values and derivation steps as it runs; a result object that keeps its
intermediate sets (`AlphaResult`, `InductiveResult`) is shown step by
step without any call. *Buys:* the How tab, Step through, and the same
object that an exam exercise is checked against. One source of truth for
the derivation, three consumers: the test suite, the app, and Learn.

## Decisions, and the reasons

- **Pure Python, no runtime dependencies for the engine.** The miners, the
conformance checks, the Petri net analysis, the CPN ML interpreter and the
simulator are standard library. *Reason:* the same code must run in a
notebook, in CI, in a student's exercise checker and in the app; a
150 MB GUI toolkit or an AGPL library in the import path would rule out
the first three. Qt is an extra (`openprocess[app]`); PM4Py is an extra
that adds boxes and is the reference the built-in miners are checked
against.
- **The framework has no Qt in it.** `openprocess.flow` works from a script,
a test and `openprocess run`; the app only draws it. *Reason:* nothing
shown in the GUI may be GUI-only, or the record would describe something
the command line cannot reproduce.
- **Thin boxes over the engines, not algorithms written in the boxes.** A
box in `openprocess/flow/boxes/` is a few lines around one engine call.
*Reason:* the Code tab shows the box and then the algorithm it calls; a
box that contained the algorithm would hide it in the wrapper, and the
engine would no longer be usable without the framework.
- **Sources from the code, not beside it.** `docs/references.md` and
`docs/definitions.md` are generated from `openprocess/references.py` and
`openprocess/mining/definitions.py`, and a test fails when they drift.
Each algorithm's module docstring cites the work it follows; the Cite
button reads the same list. *Reason:* a citation that lives only in a
document goes stale; one that lives in the code is checked with it.
- **Results keep their intermediate steps.** `AlphaResult` keeps `T_L`,
`T_I`, `T_O`, `X_L` and `Y_L`; `InductiveResult` keeps every cut and
fall-through. *Reason:* see idea 3. It also makes the exam exercises
possible: "give `Y_L` for this log" is checked against the miner's own
`Y_L`.
- **Nothing on a page computes behind the user's back.** The log page's
Discover tab used to run a miner on the spot, with no code to read and no
record; since 0.9.5 it hands the log to the canvas. Every computed figure
on a page links to the function that computed it. *Reason:* a number
with no How and no Code is exactly the black box the claim rules out.
- **Three spaces, one engine.** *Mine* (workflows), *Model* (nets drawn by
hand, the token game, the CPN simulator) and *Learn* (worksheets) are
views on the same objects. *Reason:* the net a miner discovers and the
net a student draws are the same class; the soundness check, the
definitions and the counterexample replay are written once. The spaces
exist so that each audience sees one set of rules, not so that anything is
built twice.
- **Folders, not projects.** The app opens a folder (a week of a course, a
paper's repository); files in it are the sidebar; the app and the folder
stay in step both ways. *Reason:* the record refers to files by path and
hash, and a paper's repository is already a folder. A project file of our
own would be one more thing to lose.
- **Teaching and research share one transparency mechanism.** Learn's
checks, concealment and exam mode are built on the same results and
viewers as Mine. *Reason:* a derivation that can be shown is a derivation
that can be checked, in both directions.

## What this is not

- Not a replacement for pandas, scikit-learn or PM4Py. The Science and
Predict boxes are examples of how any Python library plugs in; OpenProcess
is where their results meet a log and a model, and get recorded.
- Not a workflow engine for anything but process mining. The types are the
types of the field (logs, nets, trees, transition systems, replays,
scores); `typing.Any` is an escape hatch, not the design.
- Not a research result in itself. The algorithms follow published work and
are checked against a reference implementation
(`tests/cross_check_inductive_miner.py`); what is new is the combination:
workbench, record and derivation in one place.

## Open decisions

- **The workflow file's name.** `.cpnflow` and `"format": "cpnflow/1"`
predate the rename from CPNpy to OpenProcess. Changing them is a
compatibility decision to take with the 1.0 API freeze, with a reader that
accepts both.
- **What the 1.0 freeze covers.** The proposal: `@box` and what it reads
from a signature, the types in `openprocess.flow.types`, the `.cpnflow`
format and its record, and the `openprocess run` command line. Everything
in the GUI stays free to change.
- **Where heavy results live.** In memory and recomputed on open (what the
cache does today), or written next to the workflow so a heavy experiment
opens instantly. The record makes both reproducible.
31 changes: 31 additions & 0 deletions docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,37 @@ A `.cpnflow` file is JSON:
- `requirements_lock(record)` is a `requirements.lock` from the record's
environment.

### What "reproducible" promises

The badge beside an analysis, `--check` and the record all mean one precise
thing: **re-running the workflow gives results whose fingerprints equal the
recorded ones.** A fingerprint is the SHA-256 of a result's content (a
PNML string for a net, the rows for a table, the numbers of a score), so
"reproduces" is "byte for byte the same", not "close enough". How far that
promise travels depends on what else is the same:

| Tier | What is the same | What the record gives you | What can still differ |
|---|---|---|---|
| **1. Same machine** | the folder, the installed packages, the OpenProcess version | the badge: *Reproducible*, or exactly which input, box file, seed or version changed since the record (`differences`) | nothing, for the built-in boxes: the engines are pure Python and every random choice is seeded |
| **2. Same environment, another machine** | `requirements.lock` installed (from the export, or `--lock`), the inputs from the zip (their hashes are checked), the same Python minor version | `openprocess run --check` passes or names the box whose result differs | a box of your own that depends on something outside the record: the clock, the file system's order, an unpinned download |
| **3. Another platform or version** | the same workflow and inputs, but another OS, Python or package version | the same: a pass is a pass; a failure names the box | floating-point results of numeric boxes (SciPy's tests, matplotlib's figures as SVG, a model trained with another BLAS) can differ in the last digits, and so can their fingerprints; the built-in miners and checks are integer and set arithmetic and do not |

So a passing `--check` on the author's machine is tier 1; the claim a paper
can make is tier 2, and that is what the experiment zip is for. For tier 3,
compare the *values* the report shows rather than the fingerprints: a
fitness of 0.8712 on one platform and 0.8712 on another is the same result
even when an SVG of the plot is not.

What is in the record, and what is not:

- **In:** the SHA-256 of every input file and every box file in `boxes/`;
every seed; the OpenProcess, Python and package versions; the fingerprint
of every result; the platform.
- **Not in:** the code of OpenProcess's own boxes and the libraries they
call (their *versions* are; that is what the lock pins), the hardware, and
anything a box reads that was not declared as a `Path` setting (such a box
is reproducible only by the discipline of its author).

### Headless, and in CI

```bash
Expand Down
84 changes: 76 additions & 8 deletions openprocess/flow/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class and the target (a class, or a dotted name).
from __future__ import annotations

import importlib
import importlib.util
from typing import Any, Callable

from ..mining.log import KEY_NAME, KEY_RESOURCE, KEY_TIME, EventLog, SimpleLog
Expand All @@ -25,11 +26,49 @@ class and the target (a class, or a dotted name).


def _name(target) -> str:
"""The name a type is registered under: ``module.qualname`` for a class,
a dotted string as given. Nothing is imported here, so registering a
converter for ``"pandas.core.frame.DataFrame"`` costs nothing when pandas
is absent, and importing this module never imports pandas."""
if isinstance(target, str):
return target
return f"{target.__module__}.{target.__qualname__}"


def _resolve(dotted: str):
"""The class a dotted name points at, or None when it cannot be found.

Tries the longest module prefix first (``a.b.c.D`` as module ``a.b.c``,
attribute ``D``), then shorter ones, so both ``pandas.DataFrame`` and
``pandas.core.frame.DataFrame`` resolve to the same class. A name whose
top-level package is not installed resolves to None without importing
anything.
"""
parts = dotted.split(".")
if len(parts) < 2 or importlib.util.find_spec(parts[0]) is None:
return None
for split in range(len(parts) - 1, 0, -1):
try:
obj = importlib.import_module(".".join(parts[:split]))
except ImportError:
continue
for attribute in parts[split:]:
obj = getattr(obj, attribute, None)
if obj is None:
break
else:
if isinstance(obj, type):
return obj
return None


def _canonical(name: str) -> str:
"""``name`` as the class it points at is defined (``module.qualname``),
or ``name`` itself when it points at nothing importable."""
resolved = _resolve(name)
return name if resolved is None else _name(resolved)


def register(source, target, function: Callable) -> None:
"""``function(value) -> converted`` for a value of class ``source``."""
_converters[(_name(source), _name(target))] = function
Expand All @@ -39,20 +78,49 @@ def _class_names(value) -> list[str]:
return [f"{cls.__module__}.{cls.__qualname__}" for cls in type(value).__mro__]


def _find(value: Any, target) -> Callable | None:
"""The registered converter from ``value``'s class (or a base class) to
``target``, or None.

A class may be named by where it is imported from (``"pandas.DataFrame"``)
or where it is defined (``"pandas.core.frame.DataFrame"``, which pandas
2 reports as the class's module, while pandas 3 reports ``"pandas"``).
Both the source and the target are matched by resolving the names to the
class. Only registered names in the same top-level package as the value
or the target are resolved, so looking up a pandas conversion imports
pandas and nothing else, and only once a conversion is asked for.
"""
wanted = _name(target)
wanted_names = {wanted, _canonical(wanted)}
target_package = wanted.split(".")[0]
value_names = set(_class_names(value))
value_package = type(value).__module__.split(".")[0]

def matches(registered: str, names: set[str], package: str) -> bool:
if registered in names:
return True
return registered.split(".")[0] == package and _canonical(registered) in names

for (source, registered), function in _converters.items():
if matches(source, value_names, value_package) and \
matches(registered, wanted_names, target_package):
return function
return None


def can_convert(value: Any, target) -> bool:
return any((name, _name(target)) in _converters for name in _class_names(value))
return _name(target) in _class_names(value) or _find(value, target) is not None


def convert(value: Any, target):
"""Convert ``value`` to ``target`` (a class or a dotted name)."""
wanted = _name(target)
for name in _class_names(value):
if name == wanted:
return value
function = _converters.get((name, wanted))
if function is not None:
return function(value)
raise TypeError(f"No converter from {type(value).__name__} to {wanted}")
if wanted in _class_names(value):
return value
function = _find(value, target)
if function is None:
raise TypeError(f"No converter from {type(value).__name__} to {wanted}")
return function(value)


def converters() -> list[tuple[str, str]]:
Expand Down
Loading
Loading