From c7cdfbfd6a972b703bd483f30ec503017a7fec1c Mon Sep 17 00:00:00 2001 From: Sam Struthers <112819640+s4mstruthers@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:43:55 +0200 Subject: [PATCH] 0.11.2: pandas converters found by either name, what reproducible promises, docs/design.md, IM cross-check Describe log and Correlate failed with "No converter from EventLog to pandas.DataFrame" whenever pandas was installed: the converter was registered under the defining module (pandas.core.frame.DataFrame) and asked for by the public name. The registry now resolves both names to the class, for sources and targets alike, without importing a package until a conversion is asked for; pandas 3, which reports its classes under "pandas", is covered the same way. A test using json.JSONDecoder checks it with nothing optional installed. docs/workflows.md says what the badge and --check promise, in three tiers, and what is and is not in the record. docs/design.md is the why: the problem, the claim, the three ideas and the decisions that follow. tests/cross_check_inductive_miner.py runs the built-in Inductive Miner and PM4Py's on 406 logs and reports where they differ (baseline in its docstring and in the changelog). __version__ is not bumped here. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CxRhDznFAbwU3x78bugqqR --- CHANGELOG.md | 29 +++++ README.md | 5 +- docs/design.md | 136 ++++++++++++++++++++ docs/workflows.md | 31 +++++ openprocess/flow/convert.py | 84 ++++++++++-- tests/cross_check_inductive_miner.py | 184 +++++++++++++++++++++++++++ tests/test_flow.py | 20 +++ 7 files changed, 480 insertions(+), 9 deletions(-) create mode 100644 docs/design.md create mode 100644 tests/cross_check_inductive_miner.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e71f39e..7e71778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 50a339c..30df4d1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..59438dc --- /dev/null +++ b/docs/design.md @@ -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. diff --git a/docs/workflows.md b/docs/workflows.md index 787a072..781f321 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -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 diff --git a/openprocess/flow/convert.py b/openprocess/flow/convert.py index 41441bc..f3dcc94 100644 --- a/openprocess/flow/convert.py +++ b/openprocess/flow/convert.py @@ -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 @@ -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 @@ -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]]: diff --git a/tests/cross_check_inductive_miner.py b/tests/cross_check_inductive_miner.py new file mode 100644 index 0000000..9f3086f --- /dev/null +++ b/tests/cross_check_inductive_miner.py @@ -0,0 +1,184 @@ +"""Cross-check OpenProcess's Inductive Miner against PM4Py's, log by log. + +Not a test (pytest does not collect it: no ``test_`` prefix) but a report, +because the two implementations are *allowed* to differ: the Inductive Miner +guarantees a sound model that fits the log, not a unique one. What this +script reports is how often they differ, on which logs, and whether the +guarantees hold on the OpenProcess side regardless. Run it after touching +``openprocess/mining/discovery/inductive.py``:: + + pip install pm4py # AGPL-3.0; a reference, not a dependency + python tests/cross_check_inductive_miner.py [seed] [random logs] + +What it does, for the six textbook logs and (by default) 400 random ones: + +1. runs both miners with no noise filtering; +2. canonicalises both process trees, sorting the children of the commutative + operators × and ∧ so that ``×(a, b)`` and ``×(b, a)`` compare equal; +3. checks the two IM guarantees on the OpenProcess result: the net is sound + and token replay gives fitness 1; +4. for every differing pair, compares the ETC precision (PM4Py's + token-based precision) of the two models on the same log, so a difference + can be read as "ours is less precise", "equal" or "more precise". + +Baseline, OpenProcess 0.11.1 against PM4Py 2.7.23, seed 2026, 400 random logs: +406 logs, guarantees hold on 406, identical trees on 369 (all six textbook logs +among them), 37 differ, of which 30 less precise, 4 equal, 3 more precise. +Every one of the 37 is the same choice: after a sequence cut, groups that are +always skipped *together* (``, ``: ``a`` and ``c`` never appear +without each other) are modelled as skippable *separately*, +``→(…, ×(a,τ), ×(c,τ))``, where the reference gives ``→(…, ×(→(a,c),τ))``. +""" + +from __future__ import annotations + +import os +import random +import sys +import tempfile +import warnings +from collections import Counter +from pathlib import Path + +# The repository root, so the script runs from a checkout without installing. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from openprocess.mining.analysis import check_soundness # noqa: E402 +from openprocess.mining.conformance.token_replay import token_replay # noqa: E402 +from openprocess.mining.discovery.inductive import inductive_miner # noqa: E402 +from openprocess.mining.log import SimpleLog, parse_simple_log # noqa: E402 +from openprocess.mining.pnml import pnml_string # noqa: E402 + +TEXTBOOK = [ + "[^3, ^2, ]", + "[^3, ^4, ^2, , " + "^2, ]", + "[^45, ^42, ^38, ^22]", + "[, , ]", + "[, , ]", + "[, ]", +] + + +# --------------------------------------------------------------------------- +# Canonical tree strings, so trees from the two libraries can be compared +# --------------------------------------------------------------------------- +def canonical_openprocess(tree) -> str: + """``->(a,X(b,tau))`` for an OpenProcess ProcessTree.""" + if tree.operator is None or not tree.children: + return "tau" if tree.is_tau else str(tree.label) + symbol = {"→": "->", "×": "X", "∧": "+", "↺": "*"}[tree.operator.value] + kids = [canonical_openprocess(child) for child in tree.children] + if symbol in ("X", "+"): + kids = sorted(kids) + return f"{symbol}({','.join(kids)})" + + +def canonical_pm4py(tree) -> str: + """The same string for a PM4Py ProcessTree.""" + from pm4py.objects.process_tree.obj import Operator + if tree.operator is None: + return "tau" if tree.label is None else tree.label + symbol = {Operator.SEQUENCE: "->", Operator.XOR: "X", + Operator.PARALLEL: "+", Operator.LOOP: "*"}[tree.operator] + kids = [canonical_pm4py(child) for child in tree.children] + if symbol in ("X", "+"): + kids = sorted(kids) + return f"{symbol}({','.join(kids)})" + + +# --------------------------------------------------------------------------- +# Logs: as OpenProcess's SimpleLog (a Counter of traces) and as a PM4Py frame +# --------------------------------------------------------------------------- +def to_dataframe(log: SimpleLog): + import pandas as pd + rows, case = [], 0 + for trace, count in log.items(): + for _ in range(count): + case += 1 + for position, activity in enumerate(trace): + rows.append({"case:concept:name": f"c{case}", "concept:name": activity, + "time:timestamp": pd.Timestamp("2026-01-01") + + pd.Timedelta(seconds=position)}) + return pd.DataFrame(rows) + + +def notation(log: SimpleLog) -> str: + return "[" + ", ".join(f"<{','.join(t)}>^{n}" for t, n in log.items()) + "]" + + +def random_logs(seed: int, count: int) -> list[SimpleLog]: + """Small logs over 2–5 activities, 2–8 variants of 1–6 events each.""" + rng = random.Random(seed) + logs = [] + for _ in range(count): + alphabet = "abcdef"[: rng.randint(2, 5)] + log: SimpleLog = Counter() + for _ in range(rng.randint(2, 8)): + trace = tuple(rng.choice(alphabet) for _ in range(rng.randint(1, 6))) + log[trace] += rng.randint(1, 4) + logs.append(log) + return logs + + +# --------------------------------------------------------------------------- +# Precision of both models on the same log, through PM4Py +# --------------------------------------------------------------------------- +def precisions(log: SimpleLog, ours, theirs_tree) -> tuple[float, float]: + import pm4py + from pm4py.objects.conversion.process_tree import converter as tree_to_net + frame = to_dataframe(log) + handle, path = tempfile.mkstemp(suffix=".pnml") + os.close(handle) + try: + Path(path).write_text(pnml_string(ours), encoding="utf-8") + net, initial, final = pm4py.read_pnml(path) + finally: + os.unlink(path) + from pm4py.algo.evaluation.precision import algorithm as precision_algorithm + quiet = {"show_progress_bar": False} + mine = precision_algorithm.apply(frame, net, initial, final, parameters=quiet) + net, initial, final = tree_to_net.apply(theirs_tree) + reference = precision_algorithm.apply(frame, net, initial, final, parameters=quiet) + return mine, reference + + +def main(argv: list[str]) -> int: + warnings.filterwarnings("ignore") + import pm4py + seed = int(argv[1]) if len(argv) > 1 else 2026 + count = int(argv[2]) if len(argv) > 2 else 400 + logs = [("textbook", parse_simple_log(s)) for s in TEXTBOOK] + logs += [("random", log) for log in random_logs(seed, count)] + + same, different, broken = 0, [], [] + for kind, log in logs: + result = inductive_miner(log) + reference = pm4py.discover_process_tree_inductive(to_dataframe(log), noise_threshold=0.0) + ours, theirs = canonical_openprocess(result.tree), canonical_pm4py(reference) + sound = check_soundness(result.net).sound + fitness = token_replay(result.net, log).fitness + if not sound or abs(fitness - 1.0) > 1e-9: + broken.append((notation(log), sound, fitness)) + if ours == theirs: + same += 1 + else: + different.append((kind, log, ours, theirs, *precisions(log, result.net, reference))) + + less = sum(1 for d in different if d[4] < d[5] - 1e-9) + more = sum(1 for d in different if d[4] > d[5] + 1e-9) + print(f"OpenProcess Inductive Miner vs PM4Py {pm4py.__version__}, seed {seed}") + print(f"{len(logs)} logs: identical trees {same}, different {len(different)} " + f"(ours less precise {less}, equal {len(different) - less - more}, more precise {more})") + print(f"IM guarantees (sound, fitness 1) broken on {len(broken)} logs") + for entry in broken[:10]: + print(" ", entry) + print("\nDifferences (first 15):") + for kind, log, ours, theirs, mine, ref in different[:15]: + print(f"- [{kind}] {notation(log)}\n OpenProcess {ours} precision {mine:.3f}" + f"\n PM4Py {theirs} precision {ref:.3f}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/test_flow.py b/tests/test_flow.py index e9fff13..369b514 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -498,6 +498,26 @@ def test_bootstrap_fitness_has_an_interval(): assert table.samples and len(table.samples) == 30 and table.rows[0][0] == "mean" +def test_converter_matches_a_class_by_either_of_its_names(): + """A target may be named by where it is imported from or where it is + defined; the registry matches both. json.JSONDecoder lives in + json.decoder, so this needs no optional package.""" + from openprocess.flow import convert as c + assert c._canonical("json.JSONDecoder") == "json.decoder.JSONDecoder" + assert c._canonical("json.decoder.JSONDecoder") == "json.decoder.JSONDecoder" + assert c._canonical("no_such_package.Thing") == "no_such_package.Thing" # nothing imported + c.register(Table, "json.decoder.JSONDecoder", lambda table: len(table.rows)) + try: + table = Table("t", ["a"], [[1], [2]]) + assert c.can_convert(table, "json.JSONDecoder") + assert c.convert(table, "json.JSONDecoder") == 2 + import json + assert c.convert(table, json.JSONDecoder) == 2 + assert not c.can_convert(table, "json.JSONEncoder") + finally: + del c._converters[(c._name(Table), "json.decoder.JSONDecoder")] + + @pytest.mark.skipif(not has("pandas"), reason="pandas not installed") def test_describe_log_through_the_converter(): from openprocess.flow.boxes.science import describe_log