Skip to main content

A tiny, dependency-free explicit-state model checker for Python: safety, deadlock, reachability, liveness — and concurrency by interleaving.

Project description

musil

A tiny, dependency-free explicit-state model checker for Python. Describe a system as states + guarded transitions + invariants; musil exhaustively sweeps every reachable state — and every interleaving of concurrent actors — and hands you the shortest counterexample when something breaks.

Named for Robert Musil, the engineer-mathematician turned novelist.

from dataclasses import dataclass, replace
from musil import Action, Model, check

@dataclass(frozen=True)
class Light:
    color: str = "red"

model = Model(
    init=Light("red"),
    actions=[
        Action("go",   lambda s: s.color == "red",    lambda s: replace(s, color="green")),
        Action("slow", lambda s: s.color == "green",  lambda s: replace(s, color="yellow")),
        Action("stop", lambda s: s.color == "yellow", lambda s: replace(s, color="red")),
    ],
    invariants={"known-color": lambda s: s.color in {"red", "green", "yellow"}},
)

print(check(model))   # OK -- 3 states, no violations

Why

The expensive bugs in stateful and distributed systems are temporal and concurrent: a resource wedged forever, a race that drops data, a deadlock. Tests sample executions; a model checker proves properties over all of them. musil exists to do that in Python, as a library — no separate spec language, no external binary, no JVM. Your states are frozen dataclasses, your invariants are predicates, and the whole thing runs in pytest next to your other tests.

The key move: the model can be driven from the same data your code uses. Point transition_actions at your real allowed-transitions table and the model can't drift from the code — it is the code's table.

Install

pip install musil      # or: uv add musil

Pure standard library; Python 3.12+.

What it checks

Safety + deadlock + reachabilitycheck(model) -> Result:

result = check(model)
result.ok                 # True if every reachable state satisfied every invariant, no deadlock
print(result)             # on failure: the broken invariant (or "deadlock") + shortest trace

A state with no enabled action is a deadlock unless you mark it terminal (Model(..., terminal=lambda s: ...)) — an absorbing sink like deleted.

Concurrency, for free — model each actor's steps as actions and hand musil all of them; it fires every enabled action from every state, so all interleavings are explored:

# two non-atomic increments race; musil finds the lost update
check(Model(init=Counter(), actions=[*actor_a, *actor_b], invariants={...}))

Livenesscheck_liveness(model, goal=P) -> LivenessResult:

# "eventually P" on every run; everywhere=True checks "always eventually P" (recurrence/convergence)
check_liveness(model, goal=lambda s: s.served == s.desired, everywhere=True, fair=["reconcile"])

A liveness failure is reported as a lasso: a stem into a recurring set plus the cycle. Pass fair=[...] to assume weak fairness of those actions (an action continuously enabled along a cycle must eventually be taken), or fair_strong=[...] for strong fairness (enabled infinitely often ⇒ eventually taken — what you need for delivery over a lossy channel). Pick the weakest that makes the property hold; over-strong fairness hides real bugs.

Compose & model the networkcompose(...) builds the interleaved product of independent component models, lifting each component's invariants (name-qualified) and letting you add joint invariants over the whole; the channel kit (channel_actions, send) models a message channel — reliable / lossy / duplicating, and unordered so reordering is explored for free — so you can specify each component once and check the assembled system.

Zero-drift from a transition tabletransition_actions / status_field_actions:

from musil import status_field_actions, terminal_states
model = Model(
    init=Service("pending"),
    actions=status_field_actions(ALLOWED["service"]),     # generated from YOUR table
    terminal=lambda s: s.status in terminal_states(ALLOWED["service"]),
)

Visualizeto_dot(model) returns Graphviz DOT (dot -Tsvg); pass highlight=[s.state for s in result.trace] to colour a counterexample.

Verify the implementation, not just the designsimulate runs your real event-driven node code under a deterministic, fault-injecting network (loss / duplication / reordering), reproducible from a seed, holding it to a model (check_refinement) plus invariants and a convergence goal. It's the FoundationDB/TigerBeetle deterministic- simulation-testing technique as a pure-Python library — bug finding, not proof:

from musil import simulate, NetworkModel
report = simulate(node_factory, seeds=range(1000), snapshot=snapshot,
                  network=NetworkModel(loss=0.3, max_latency=3),
                  model=spec, abstraction=lift, goal=lambda w: w.applied == w.desired)
if not report.ok:
    print(report.failure)   # which seed, which step, which world — re-run seeds=[that_seed] to replay

See Verifying a distributed system and the runnable examples/route_delivery.py.

How it compares

what it is spec language runs the real code? liveness
musil in-Python library, explicit-state Python (frozen dataclasses) the model can be driven from your code's tables safety + weak/strong-fairness liveness
TLA+ / TLC standalone checker TLA+ (math) no (separate model) full temporal logic
P DSL + systematic testing, compiles to C P (state machines) yes (executable model) safety + liveness
Stateright Rust library, model-check + run Rust yes (same actors on a real network) safety + liveness
FizzBee Go binary, Python-like DSL .fizz no safety + basic liveness

musil is the smallest member of this family: pick it when your state space is bounded and small, you want the model in your test suite with zero new tooling, and the win is catching races / deadlocks / stuck states and proving convergence.

Honest limitations

  • Explicit-state: it enumerates reachable states, so it's for bounded models. Big or infinite state spaces blow up (use a max_states cap; the result reports truncated). Symbolic/SMT tools (TLA+'s Apalache, etc.) scale further.
  • No partial-order reduction (yet): heavy concurrency interleaving can be expensive.
  • Liveness is fairness-based, not full LTL: <>P and []<>P under weak and strong fairness, not arbitrary temporal-logic formulae.
  • It checks the model. Whether your implementation refines the model is a separate question — generate_traces + replay give you conformance testing (generate from the model, replay against the code) as a pragmatic bridge.

Releasing

The version in pyproject.toml is the single source of truth, and releases are automated end to end:

  1. pre-commit auto-bumps the patch version whenever a commit touches src/ (run make install-hooks once after cloning). Doc/test/example/config-only commits don't bump.
  2. version-guard (CI) enforces the same rule non-bypassably: a push or MR that changes src/ without a version bump fails the pipeline. This catches --no-verify and unhooked clones.
  3. tag (CI) runs on a green main pipeline: if pyproject's version has no matching vX.Y.Z tag yet, it creates and pushes one. That tag push triggers a fresh pipeline whose publish job ships to PyPI. So a merge to main that bumps the version releases itself.

For an intentional minor/major release, bump deliberately before committing: make bump TYPE=minor.

One-time setup

PyPI — OIDC Trusted Publishing (no API token is stored anywhere). Account → Publishing → add a pending GitLab publisher (or add it to the project after the first manual upload):

Field Value
PyPI Project Name musil
Namespace jorgeecardona
Project name (repo) musil
Top-level pipeline file .gitlab-ci.yml
Environment name pypi

GitLab — TAG_PUSH_TOKEN (so the tag job can push; CI_JOB_TOKEN can't). Settings → Access Tokens → create a Project Access Token with role Developer (or higher) and scope write_repository, then Settings → CI/CD → Variables → add TAG_PUSH_TOKEN = that token, masked and protected. Until this variable exists the tag job stays dormant and versions won't auto-release — you'd cut tags manually with make release.

Manual release (fallback)

make release        # tags HEAD as v<pyproject version> and pushes -> triggers publish

The tag pipeline builds the sdist+wheel and the publish job exchanges GitLab's OIDC token for a short-lived PyPI token and uploads. Pushes and MRs run lint + typecheck + tests only.

License

MIT © Jorge Cardona. See LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

musil-0.1.1.tar.gz (96.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

musil-0.1.1-py3-none-any.whl (33.1 kB view details)

Uploaded Python 3

File details

Details for the file musil-0.1.1.tar.gz.

File metadata

  • Download URL: musil-0.1.1.tar.gz
  • Upload date:
  • Size: 96.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for musil-0.1.1.tar.gz
Algorithm Hash digest
SHA256 4673423eb8fa799ef7af9ff5f18430f2da037ee2f71f1b8935cc1b0c4be77642
MD5 972c08dc3111e0f169eee36f94cc262b
BLAKE2b-256 1e36921ddbefbae31d6a30f9b2b8f439679a4806b873d4ac6493f5e49e442b5f

See more details on using hashes here.

File details

Details for the file musil-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: musil-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 33.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for musil-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 547ae34a8c79317295fb4532f7e272b77d76708a380247675fa58ac6b36ee6ab
MD5 2ee64f76fdba46adb00d6f052b36cfd1
BLAKE2b-256 450eaa0c9c9ce83272b89b539592570e308fba792f4706f28166b3cb67f73a07

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page