Changelog¶
All notable changes to musil are documented here. Versioning is SemVer.
Added — block_volume_faults (#40): the block-volume fault contract for stateful nodes¶
musil.environments.storage.block_volume_faults(...)is anEnvironmentSpecfactory for the fault class behind most stateful-node data-loss incidents:storage:volume-detached,storage:wrong-volume-attached(a different volume's data — a silent tenant wipe),storage:empty-volume-attached, andstorage:detach-during-write. Each fault is opt-in via acan_/on_callable pair (both or neither — a half-pair raises; at least one required). Two assumptions are pre-wired and surfaced inOpenResult.unverified_assumptions:VOLUME_ATTACH_EXCLUSIVE(single-writer attach) andDATA_PRESENCE_SENSED(a data-presence sense eventually resolves — the basis for provable recovery). Stateful-recovery models compose the shared contract viacheck_openinstead of hand-rolling these faults. Tests pin thatcheck_openfinds the wrong-volume data-loss (the fault named in the counterexample), a sensing node is safe against the same fault, and a sensing-recovering node reaches the recovered state on every run under strong fairness on serve.
Added — coverage_cached (#41): content-addressed caching for coverage()¶
coverage_cached(model, *, version, cache_dir, max_states, key)inmusil.metricsis the parallel ofcheck_cached: it returns a cachedCoveragewhen the model (by readable structure +version) and options are unchanged, otherwise runscoverage, stores it, and returns it — so a coverage regression test (assert not coverage_cached(model, version="1").dead_actions) skips the whole re-exploration on every CI run and pre-push hook once the model is unchanged. Sameversion-bump discipline ascheck_cached(bump when an opaque action/invariant/guard body changes). The two now share one internal_load_or_storehelper and store under distinct filenames ({digest}.pklvs{digest}.coverage.pkl), so a verdict cache and a coverage cache for the same model never collide.
Fixed — expose __version__ (#39), and env docstrings show the non-deprecated check_open import (#38)¶
musil.__version__(#39) — the package exposes__version__as the single source of truth: a literal insrc/musil/__init__.pythatpyproject.tomlderives from dynamically (dynamic = ["version"]+[tool.hatch.version], read by regex without importing), so the runtime version and the distribution metadata never disagree and the number lives in exactly one place (it was previously a static duplicate inpyproject.toml). Previouslyimport musil; musil.__version__raisedAttributeErrorthrough the deprecation__getattr__.scripts/bump-versionand the pre-commit hook rewrite this literal.- Environment docstrings (#38) — every
musil.environments.*factory's usage example now importsfrom musil.env import check_open, so copy-pasting it no longer triggers the 0.35 deprecation warning that the barecheck_open(model, env)would (which resolves via the old top-level shim).
Changed — every submodule now declares __all__¶
- With the 0.35 reorg the submodules became the public entry points, so each of the 28 now declares an explicit
__all__of its public names (they already carried module docstrings). This curatesfrom musil.<module> import *, giveshelp(musil.<module>)and the mkdocstrings API reference a precise public surface, and makes each submodule a first-class namespace. No behaviour change for explicit imports.
Added — deprecation shim so the pre-0.35 flat imports keep working (with a warning)¶
- A module-level
__getattr__on themusilpackage softens the 0.35 API break into deprecate-then-remove.from musil import reach_probabilitystill resolves — to the genuinemusil.probabilistic.reach_probability— but raises aDeprecationWarningnaming the new submodule path. Core (top-level) names are unaffected (the shim is never consulted for them), and an unknown name still raisesAttributeError. A test asserts every one of the 124 moved names resolves in its named submodule, so the shim table cannot drift. The names will stop being importable from the top level in a future release; seedocs/migrating.md.
Changed (BREAKING) — public API reorganised: a small core top level, everything else in submodules¶
- The top-level
musilpackage now exports only the core checking API (~34 names): modelling (Model,Action,Invariant,EdgeInvariant), the checks (check,check_all,check_of,explore,check_liveness,check_ltl), results and traces (Result,Step,Verdict,Graph,LivenessResult,format_trace,witness,path_to,reaches,states_where), the test assertions (assert_ok,assert_violates), and the LTL formula AST. The previous flat namespace re-exported all 158 names — too many to discover or teach, and it hid the layering. - Every specialised layer now lives in its own submodule, imported directly:
musil.probabilistic,musil.sim,musil.env,musil.metrics,musil.fsm,musil.refine,musil.conformance,musil.induction,musil.symmetry,musil.por,musil.faults,musil.channels,musil.compose,musil.clock,musil.reconciler,musil.patterns,musil.counterexample,musil.diffing,musil.cache,musil.profiling,musil.doctor,musil.citations,musil.graph,musil.history,musil.tracing(plus a few names demoted withinmusil.core/musil.ltl). Nothing was renamed or removed — only the import path changed, e.g.from musil import reach_probabilitybecomesfrom musil.probabilistic import reach_probability. - Full old→new table and the quick fix:
docs/migrating.md. All 30 examples, the whole test suite, and the docs were updated to the new paths;pip install musilstill pulls zero third-party packages, and the gate (ruff, pyright, 435 tests,mkdocs --strict) stays green.
Tests — random-model property test for partial-order reduction's soundness gate¶
- Added
tests/test_por_properties.py. POR is the most dangerous feature to get wrong — an unsound reduction silently drops the path to a violation and turns a real bug into a green check. Over 80 random concurrent models x a family of ample functions (some sound, some deliberately unsound), the test pins thatexplore_pornever invents a state and — the critical guarantee —por_soundhas no false positive: whenever a reduction flips the safety verdict (checkon the reduced graph disagrees with the full model),por_soundreturns False. This gives partial-order reduction the same broad metamorphic guard the probabilistic, core/LTL, and liveness layers already have; the fixed-seed sweep exercised 156 gate-approved (verdict-preserving) and 84 verdict-flipping (gate-rejected) reductions, complementing the fixed cases intest_por.py.
Docs — coverage & vacuity example gets a docs page¶
examples/coverage_vacuity.py(a green-check account with one dead action and one vacuously-true invariant, found bycoverage/invariant_coverage) now has a docs page under the "Analysis & proof" nav category, beside induction. This completes the invariant that every README-listed verification mode has a discoverable worked example: coverage/vacuity was surfaced in the README capability list two commits ago but its example was not on the site.
Examples — induction (the counterexample-to-induction workflow)¶
examples/induction.py: the worked examplecheck_inductivewas missing (every other major capability has one). A counter steps by two — reachable{0,2,4,6,8}— socheckis green onx <= 8, but that invariant is NOT inductive: from the unreachable odd pre-statex = 7one step reachesx = 9.check_inductivereturns exactly that counterexample-to-induction (Counter(7) --step--> Counter(9)), and strengthening the invariant tox <= 8 and x evenproves it at unbounded depth. The IC3/PDR workflow in miniature. Example + docs page (new "Proof techniques" nav category) + subprocess test, so all 30 examples run in the gate.
Docs — surface induction (check_inductive) in the README capability list¶
check_inductive(prove an invariant by k-induction so a pass holds at unbounded depth, with a counterexample-to-induction on failure) was mentioned only in the README Limitations caveat, not presented as a first-class capability. Added a "What it checks" entry beside the coverage/vacuity one — the README capability list now covers all the major verification modes (safety, concurrency, liveness, LTL, probabilistic, confluence, coverage/vacuity, induction). README changes reach PyPI on the next version bump.
Docs — surface coverage & vacuity in the README capability list¶
- The README "What it checks" section gained a "coverage & vacuity" entry:
coverage(which actions never fired, were always enabled, or only ever self-looped),invariant_coverage(candidate-vacuous invariants never put at risk),assert_covers, andportfolio_coverage. This capability — the answer to "is a green check meaningful?", which resolved the filed "a green check can't say what was exercised" issue — was documented in the API reference but absent from the front-page capability list. README changes reach PyPI on the next version bump.
Docs — general-PLTL engine put ON HOLD with a risk assessment (piece 1 stays shipped)¶
GENERAL_PLTL_NOTES.mdgains a "Risk assessment" section. The LDBA accepting-component construction has two silent-corruption traps: ∃-vs-∀ acceptance (the accepting component must recognise "∃ a run visitingFi.o.", but a naive Miyano–Hayashi breakpoint captures the ∀ / universal condition — a different language), and deterministic Büchi being strictly weaker than nondeterministic (no DBA exists forFG a, so the accepting part needs the intricate Sickert–Esparza master/slave machinery, not a generic determinisation). Its pieces are also not piece-wise quantitatively testable — only qualitative language-equivalence until the full DTMC path is wired — so a subtly-wrong automaton passes the qualitative test and ships a WRONG probability. Therefore the engine must be built deliberately end-to-end against a reference (Rabinizer / ltl2ldba) with the differential oracle as the gate, NOT as autonomous per-tick increments. Piece 1 (degeneralize) stays shipped and sound;probability_ltlkeeps raisingNotImplementedErrorfor genuinely-nesting formulas — the current, correct behaviour is preferred over a fast wrong number.
Docs — general-PLTL plan corrected: determinise on-the-fly over model states, not 2^AP¶
GENERAL_PLTL_NOTES.mdgains an "Alphabet" section fixing an architectural assumption in the LDBA plan. A textbook determinisation branches over the propositional alphabet2^AP, but musil's_gpvwcompiles each tableau node's literals into an OPAQUE guard (_label_guard(q.old)), andBuchi/GeneralizedBuchikeep only thatCallable— the symbolic label is gone, so the automaton cannot be determinised model-agnostically from the object alone. The corrected plan determinises ON-THE-FLY over the model's reachable-state alphabet (finite, guards evaluable): the LDBA's subset + breakpoint construction runs directly on product states(s, D), merging former steps 2 and 3. The hard part (the breakpoint construction) is unchanged; found by inspecting_gpvwbefore building the wrong (model-agnostic) shape.
Added — GeneralizedBuchi.degeneralize() (generalized Büchi → single-set Büchi)¶
GeneralizedBuchi.degeneralize() -> Buchiimplements the standard counter construction: one copy of the automaton per acceptance set, advancing the counter when the current copy's set is visited, accepting in copy 0's set. It recognises the same ω-language —degeneralize().has_accepting_run(g) == has_accepting_run(g)for every model — so a run visits the single accepting set infinitely often iff it visits every generalized set infinitely often. With no acceptance sets, every infinite run accepts. This is piece 1 of the general probabilistic-LTL (LDBA) engine planned inGENERAL_PLTL_NOTES.md— a pure automata transformation, no probability yet. Corpus pins language preservation on a toggling model for k=1 and k=2 formulas (and their negations); a random-model property test intest_core_ltl_properties.pyguards it broadly.
Docs — general probabilistic LTL: vetted design note (LDBA, not Safra)¶
src/musil/GENERAL_PLTL_NOTES.mdrecords the full design for the one remaining piece of the probabilistic layer:probability_ltlfor formulas whose operands are themselves temporal (X(F a),(F a) U b, boolean combinations). Key decision: build a limit-deterministic Büchi automaton (LDBA), not a full Safra/Rabin determinization — an LDBA is sound for quantitative DTMC/MDP model checking and far simpler to construct in-house, reusing the GPVW front-end (GeneralizedBuchi.from_ltl) plus the existing BSCC / MEC decompositions and the exact reachability solve. The note gives the construction (subset + breakpoint suspension), the soundness argument (a suitable LDBA makes the product a Markov chain again, where a plain NBA has no well-defined measure), a built-in differential oracle (the engine must agree with the shipped reductions on the wholeF/X/G/GF/FG/U/R/Wstate-predicate fragment), and an incremental each-tick-green build plan. Deferred as a deliberate multi-tick effort;probability_ltlkeeps raisingNotImplementedErroroutside the reducible fragment until it lands.
Added — probability_ltl now supports X p (next), completing every operator on state predicates¶
probability_ltl(model, Next(p))is the probabilitypholds at the next step — a one-step push of the initial distribution (DTMC), or the max/min over the initial state's moves (MDP). An absorbing initial state self-loops, soX pcontributes[p(init)]there — consistent with how the layer's GF/FG BSCC analysis already treats an absorbing state as a self-looping component (verified:GF p = FG p = 1at an absorbingp-state). Corpus pinsX A = 1/2on a uniform branch, the absorbing self-loop case (X p = [p(init)]), and the MDP max/min. With F/G/GF/FG and U/R/W already shipped, every LTL operator applied to state predicates is now supported; only operands that are themselves temporal (e.g.X(F a)) need the deferred deterministic-automaton engine.
Added — probability_ltl now supports a W b (weak until), completing U / R / W¶
probability_ltl(model, WeakUntil(a, b))finishes the binary temporal operators for state predicates. Weak until is(a U b) ∨ G a = b R (a∨b), soP(a W b) = 1 - reach_probability(¬a∧¬b, avoid=b)(DTMC), and for an MDP the max/min flips like release. Verified on every edge case:a=true → 1,b=false → P(G a),b=true → 1,false W b → b-at-init.G pis alsop W false, and the corpus cross-checksprobability_ltl(WeakUntil(p, false))againstprobability_ltl(Always(p))for the DTMC and the MDP (max and min). With U and R shipped in the previous entries, all three binary temporal operators of state predicates now reduce to constrained reachability with no automaton; only operands that are themselves temporal need the deferred deterministic-automaton engine.
Added — probability_ltl now supports a R b (release of two state predicates)¶
probability_ltl(model, Release(a, b))completes the binary temporal operators for state predicates. Release is the until dual,a R b = ¬(¬a U ¬b), soP(a R b) = 1 - P(¬a U ¬b) = 1 - reach_probability(¬b, avoid=a)(DTMC), and for an MDP the max/min flips:P_max(a R b) = 1 - P_min(¬a U ¬b),P_min(a R b) = 1 - P_max(...)— again no automaton.G pis the special casefalse R p, and the corpus cross-checksprobability_ltl(Release(false, p))againstprobability_ltl(Always(p))for the DTMC and for the MDP (max and min), plus a non-triviala R bagainst the closed-form1 - P(¬a U ¬b). Nested release (a temporal operand) still raisesNotImplementedError.
Added — probability_ltl now supports a U b (until of two state predicates)¶
probability_ltl(model, Until(a, b))extends the reducible fragment beyondF/G/GF/FG. Strong until of two STATE predicates is exactly constrained reachability — the probability of reaching abstate without first passing through a¬astate — so it reduces directly toreach_probability(b, avoid=¬a)(DTMC) andreach_probability_mdp(b, avoid=¬a, optimize=...)(MDP, max and min), with no automaton. This corrects a documented imprecision:PROBABILISTIC_NOTES.mdhad listedUntilunder "needs a Safra/Rabin determinization", but only NESTED until (a temporal operand, e.g.(F a) U b) does — until of state predicates is a reachability query. Corpus pinsP(¬Z U A) = 1/2against the constrained-reachability answer for both DTMC and MDP (max/min), and still assertsNotImplementedErrorfor an until with a temporal operand.
Tests — every example now runs end-to-end in the gate¶
- Added the remaining nine
test_example_*.py(byzantine_service, cosim_master_scaling, k8s_scheduler, lost_update, mutex, open_system_composed, service_lifecycle, tcas, traffic_light), each running the real example as a subprocess and asserting exit 0 plus a headline output line. With the four from the previous entry, all 29 examples are now executed by the test suite — an example can no longer lint clean but crash at runtime without CI noticing.
Tests — run the four new examples end-to-end in the gate¶
- Added
test_example_{monty_hall,gamblers_ruin,river_crossing,needham_schroeder}.py, each running the real example as a subprocess and asserting exit 0 (its internal exact-fraction / attack-found asserts held) plus the headline output. This matches the existing one-subprocess-test-per-example pattern and guards each new example's__main__(prints,format_trace, self-asserts), which the corpus model tests did not exercise. (Nine pre-existing examples still lack such a test; a separate follow-up.)
Docs — surface the new famous examples on the front page and in the textbook¶
- The README safety section now points to
examples/needham_schroeder.pyas the flagship counterexample (Lowe's attack), and the probabilistic examples list adds Monty Hall and gambler's ruin. The executable textbook (docs/textbook.md) gains a Needham-Schroeder row under Distributed protocols. Keeps the shop window current with the examples shipped over the last few ticks (README changes reach PyPI on the next version bump).
Examples — Needham-Schroeder + Lowe's attack (a checker finding a famous security bug)¶
examples/needham_schroeder.py: model checking's most cited success.checkon the original Needham-Schroeder public-key protocol returns a counterexample whose trace is Gavin Lowe's 1995 man-in-the-middle attack (an authentication-invariant violation);checkon Lowe's fix (message 2 names the responder) is clean. The build follows the previous tick's design note: the intruder's knowledge is the network (no separate message pool to explode), giving a bounded, sound Dolev-Yao intruder in 46 reachable states. Corpus pins both verdicts — attack found on the original, clean on the fix — the differential that validates the model. This retires the deferral recorded inNEEDHAM_SCHROEDER_NOTES.md.
Docs — Needham-Schroeder + Lowe's attack: vetted design note (build deferred one tick)¶
src/musil/NEEDHAM_SCHROEDER_NOTES.mdrecords the full model for demonstrating Gavin Lowe's 1995 man-in-the-middle attack on the Needham-Schroeder public-key protocol as a safety-invariant counterexample: the state representation, the honest and Dolev-Yao intruder actions, the authentication invariant, the six-message attack trace, a soundness argument (the violation is reachable only via the real attack), and the broken→attack / fixed→clean differential that validates it. Deferred one increment on a measured finding: the naive persistent-network intruder explodes (a prototype truncated at 200k states before the responder's actions were added), so the build needs a bounded-but-sound intruder (typed fabrication + consume-on-read) prototyped undermax_statesfirst. Consistent with the enumeration limitation.
Examples — River crossing (planning by reachability)¶
examples/river_crossing.py: the wolf/goat/cabbage puzzle, solved by using the checker as a PLANNER. Every crossing is guarded so it may fire only if the resulting bank configuration is safe, so illegal positions are never entered; thenwitness(graph, everyone_across)returns the shortest path to the goal — the seven-crossing solution — andformat_tracerenders it. The docs page also shows the equivalent idiom: giving the model the invariant "the goal is never reached" makescheckreturn the same plan as a counterexample (planning == refutation). Ten reachable states, one exact answer, no probabilities. Corpus pins the 10-state space, the 7-crossing witness, and the refutation equivalence.
Examples — Gambler's ruin (the canonical absorbing random walk)¶
examples/gamblers_ruin.py: the textbook 1-D random walk between two absorbing barriers, deriving three classical closed forms exactly — fair-game goal probabilityi / N(reach_probability), expected durationi * (N - i)(expected_rewardwith cost 1 per step), and the biased-walk goal probability(1 - (q/p)^i) / (1 - (q/p)^N)via integerweights (24/31forp = 2/3, start 2 of 5). The corpus already pinned the fairi/Nand thei(N-i)duration; this adds the biased-walk closed form. Docs page uses content tabs (fair vs biased) and a collapsible martingale derivation.
Examples — Monty Hall (the exact 2/3 result)¶
examples/monty_hall.py: the famous switch-vs-stay problem as two fixed-policy DTMCs, derivingP(win | switch) = Fraction(2, 3)andP(win | stay) = Fraction(1, 3)exactly (no sampling, no floating point). A deliberate modelling note: it is a DTMC per strategy, not an MDP, because musil's MDP policies are state-based and the state carries the car's position — a fully-observable scheduler could "see" the car and win with probability 1, but the real player cannot, so the strategy is fixed in the model's structure. All branching is uniform, so noweightfunction is needed. Corpus pins both closed forms; the docs page uses the new Material features (content tabs, code annotations, a collapsible derivation).
Docs — modern Material features, and a check_inductive note in Limitations¶
- Enabled a curated set of Material for MkDocs features (instant navigation, code annotations, linked content
tabs, collapsible
details, richer inline formatting) — all shipped withmkdocs-material, no new dependency.mkdocs build --strictstays green. - The README Limitations section now notes the one escape hatch on the depth axis:
check_inductiveproves an invariant by k-induction so a pass holds at unbounded depth, but it still enumerates a boundeddomain, so an infinite value domain or an unbounded number of components still needs a symbolic solver. It moves the bound from depth to state variables; it does not remove it.
Added — assert_probability: the quantitative test-assertion helper¶
assert_probability(actual, equals=/at_least=/at_most=, label=...)asserts an exact probability or expected value meets a bound, raisingAssertionErrorwith the actual value on failure — the quantitative analog ofassert_ok/assert_violates/assert_covers, and the natural way to write the threshold properties a stochastic system needs (assert_probability(reach_probability(m, is_bad), at_most=Fraction(1, 100))). Bounds areint/Fractiononly (notfloat— the layer is exact, andFraction(0.01) != 1/100); at least one bound is required. Corpus pins the thresholds, ranges, int bounds, failure messages, and the no-bound usage error.
Fixed — expected_reward_mdp refuses non-positive costs (unbounded), instead of a wrong finite value¶
- The unbounded stochastic shortest/longest path assumes every step off the target costs strictly
> 0(so an improper policy accumulates infinite cost — the basis of policy iteration). A zero- or negative-cost step silently returned a WRONG finite value (e.g. a negative-cost loop a min scheduler exploits forever gave a finite number instead of-inf).expected_reward_mdpnow enforces the documented contract: a non-positive cost off the target on the unbounded path raisesValueError(matching the negative-weight guard's refuse-rather-than-mislead philosophy).steps=kis a finite sum and is unaffected (accepts any cost). The DTMCexpected_reward(a linear solve, no SSP properness argument) is likewise unaffected. Corpus pins that a negative-cost loop is refused unbounded but accepted withsteps=k;PROBABILISTIC_NOTES.mdnotes exact zero/negative-cost handling remains the deferred increment.
Docs — clarify that patterns.always / absent report deadlocks (unlike check_ltl(Always))¶
- Following the
G-is-pure-safety fix,check_ltl(Always(p))ignores deadlocks but thealways/absentpatterns lower tocheckand so still report a deadlock as a failure. The pattern docstrings and thepatternsmodule docstring now spell out that distinction (the patterns are the safety-check surface: "pinvariant AND no deadlock";check_ltl(Always(p))is LTLG p, which ignores deadlocks). A corpus assertion pins thatalways(deadlocking_model, true)reportskind="deadlock"whilecheck_ltl(Always(true))holds. Docstring-only, no behaviour change.
Release — 0.34.46 republishes the corrected README (PyPI long-description)¶
- The maintainer sections (
Development & CI,Releasing, the PyPI-OIDC one-time-setup table) were moved out of the README intoCONTRIBUTING.mdin a docs-only commit that did NOT bump the version, so 0.34.45 shipped to PyPI with the OLD README long-description (the setup table still showed on the project page). This patch bump forces:releaseto republish with the user-facing README. No code change.
Hardening — metamorphic property tests for the liveness layer¶
- New
tests/test_liveness_properties.pycompletes the random-model differential sweep over the last soundness-critical layer (the fair-cycle / Emerson-Lei sub-SCC search, where the #22 bug lived): RECURRENCE (everywhere=True, GF p) implies the goal is reachable from every reachable state (reaches== all states) — liveness by fair-cycle search cross-checked against reachability by backward BFS; fairness is MONOTONE (assuming more fair actions never breaks a holding property); and recurrence (GF p) implies plain reachability liveness (F p). Pure stdlib, fixed seed. (Writing it also confirmed the subtlety that plain<>Pis satisfied by the goal holding at the initial state alone — it does NOT imply reachability everywhere; only recurrence does.)
Hardening — metamorphic property tests for the core and LTL layers¶
- New
tests/test_core_ltl_properties.pyextends the random-model differential approach to the soundness-critical paths:check_of(explore(m), m)gives the same verdict (ok / kind / broken invariant) ascheck(m)over 60 random models (independent traversal code), and the general Büchi engine agrees with the proven reductions —F/G/GFforced through the engine (true U p/false R p/G(true U p)) equal their reducible forms over 40 random models × random predicates (360 comparisons). This broadly exercises the riskiest LTL code and confirms theG-is-pure-safety fix holds beyond the fixed corpus models. Pure stdlib, fixed seed, fast.
Hardening — metamorphic property tests for the probabilistic layer¶
- New
tests/test_probabilistic_properties.py: invariants that must hold on EVERY DTMC/MDP, checked over many randomly generated small models (fixed seed, exact rational, pure stdlib). It pins RELATIONSHIPS the known-verdict corpus does not —reach_probability(steps=k)is in[0,1], non-decreasing ink, and never exceeds the unbounded probability (a real cross-check: bounded uses_bounded_values, unbounded uses the linear solve);transient_distributionalways sums to 1; MDPmax >= minwith the uniform DTMC between them;long_run_probabilityin[0,1]; andprobability_ltl(F target) == reach_probability(target). This is the differential-oracle role (e.g. against a future native port), complementing the exact known verdicts.
Fixed — check_ltl(Always(p)) is now pure LTL safety (ignores deadlocks), resolving the G inconsistency¶
G p(check_ltlofAlwaysof a state predicate) is a PURE safety property: it holds iffpholds in every reachable state and IGNORES deadlocks (deadlock-freedom is a separate property —check, or aG(enabled)claim). Previously theAlways(p)reduction ran plaincheck, which reports an undeclared deadlock as a violation, socheck_ltl(Always(p))returned False on a deadlocking model even whenpheld everywhere — disagreeing with the engine's stutter-extendedG p(its equivalentfalse R p). The reduction now sets every dead-end terminal beforecheck, so it reports only invariant violations; the two semantically-equal formulas now agree everywhere. This is a behaviour change ONLY for models with an undeclared deadlock checked withAlways(p).patterns.always/patterns.absentstay directcheckcalls and still report deadlocks (the "safety check" surface, distinct from pure-LTLG). The differential now coversGon the deadlocking model;test_ltl_always_is_pure_safety_and_ignores_deadlockpins it.
Hardening — LTL engine-vs-reduction differential (from the prior version, now with G included)¶
- New corpus differential
test_ltl_general_engine_agrees_with_the_reductions: forcesF/G/GFthrough the general Büchi engine (true U p,false R p,G(true U p)) and checks they match the provencheck/check_livenessreductions on classic models — the cross-checkLTL_NOTES.mdhad called for, strengthening the corpus's differential-oracle role (also for a future native port). - The differential surfaced a real inconsistency: on a DEADLOCKING model,
check_ltl(Always(p))reduces tocheck, which reports the deadlock as a violation, so it returns False even whenpholds everywhere — while the engine's stutter-extendedG p(viafalse R p) returns True. So the semantically-equal formulasAlways(p)andfalse R pdisagree on deadlocking models.F/GFare unaffected. Documented inLTL_NOTES.md(KNOWN INCONSISTENCY) with the fix as a maintainer semantics decision (recommend: reduceAlways(p)to a pure invariant sweep with no deadlock detection, so equal formulas get equal verdicts); the differential excludesGon the deadlocking model until it is resolved. No behaviour change yet.
Added — long_run_probability_mdp: MDP steady-state fraction of time (fills the DTMC/MDP symmetry)¶
long_run_probability_mdp(model, target, moves=..., optimize="max"|"min")returns the exact max/min long-run fraction of time an MDP spends in a target set over all schedulers — the MDP analog oflong_run_probability(asreach_probability_mdpis toreach_probability), the one quantity that had a DTMC form but no MDP one. It is the indicator special case oflong_run_reward_mdpand delegates to it (same reference-oracle enumeration andmax_policiescap). Corpus pins a scheduler steering into an absorbing target (max = 1,min = 0) and a probabilistic coin that reaches it half the time (max = 1/2).
Added — examples/server_utilization.py: reward-rate answers (steady-state / transient / policy), worked¶
- A worked example for the three newest reward-RATE capabilities, which had no example: a server that is BUSY
or IDLE.
long_run_rewardgives the steady-state utilisation (2/3) and a revenue rate (19/3);transient_distributiongives the warm-upP(BUSY at step k)climbing toward2/3(0, 1/2, 5/8, 21/32, 85/128); andlong_run_reward_mdpgives the best/worst long-run utilisation when a scheduler chooses an ECO vs TURBO regime at each busy step (max = 2/3ECO,min = 1/2TURBO). The example asserts the exact fractions and a subprocess test pins them;docs/examples/server_utilization.mdrenders it. No new library code.
Added — long_run_reward_mdp: exact MDP long-run average reward (reference oracle)¶
long_run_reward_mdp(model, reward, moves=..., optimize="max"|"min")returns the exact max/min steady-state reward RATE over all schedulers of an MDP — the last DTMC capability without an MDP analog. A finite MDP has an average-optimal MEMORYLESS DETERMINISTIC policy (Puterman), so the optimum equals the best/worst over the finitely many such policies: it enumerates them, induces each one's DTMC, and takes the extreme oflong_run_reward(shared_long_run_reward_of). Exact and dependency-free — the pure-Python Tier-0 oracle. Exponential in the number of choice states, so for SMALL MDPs;max_policiescaps the enumeration with a loudValueError. An efficient average-reward policy iteration (LP-free, linear solves) is the documented future accelerator, to be differential-tested against this oracle (PROBABILISTIC_NOTES.md). Corpus pins a scheduler steering into a high-rate vs low-rate absorbing cycle (max = 10,min = 2) and a probabilistic coin that cannot (max = min = 6).
Added — transient_distribution: exact point-in-time state distribution after k steps¶
transient_distribution(model, steps, weight=...)returns the exact probability distribution over states AFTER EXACTLYstepstransitions of the DTMC (PRISM's transient / instantaneous analysis) asdict[state, Fraction]summing to 1 — a forward pushpi_0 = delta_init,pi_{i+1}(s') = sum_s pi_i(s) P(s,s'), exact and with no fixed point; an absorbing state keeps its mass. It is the point-in-time complement to the steady-state limit (long_run_probability/long_run_reward) and to within-kreachability (reach_probability(steps=k)): from it, "in a set AT step k" or "expected reward AT step k" follow by summing. Corpus pins the fair-coin loop (pi_k(done) = 1-(1/2)^k) and a periodic 2-cycle whose point distribution ALTERNATES, unlike its1/2steady state.
Added — long_run_reward: exact DTMC long-run average reward (steady-state reward rate)¶
long_run_reward(model, reward, weight=...)returns the exact steady-state reward rate of a DTMC (PRISM'sR=? [S]): in the limit, the mean ofreward(state)over the run,sum_B reach(B) * sum_{s in B} pi_B(s) * reward(s)— the same BSCC + stationary-distribution machinery aslong_run_probability, with a per-staterewardin place of the target indicator.rewardMAY be negative (a net cost rate).long_run_probabilityis the special casereward = 1_targetand now DELEGATES tolong_run_reward(one BSCC loop, no drift). Corpus pins a periodic 2-cycle with rates 3/1 (= 2), a transient split with rates 10/2 (= 6), and a negative rate (= -1/2). No LP (this is the DTMC average-reward; the MDP long-run average still needs one).
Added — step-bounded expected reward: steps=k on expected_reward / expected_reward_mdp¶
expected_rewardandexpected_reward_mdpgainsteps=k: the expected cost accumulated until the target is reached ORksteps elapse — a finite backward induction (E_0 = 0,E_i(s) = cost(s) + sum_s' P(s,s') E_{i-1}(s')for the DTMC, optimised over the moves for the MDP min/max). Always finite, so unlike the unbounded expected reward it needs NO almost-sure / proper-policy guard. Mirrors thesteps=already onreach_probability/reach_probability_mdp. Corpus pins the closed formE_k = 2(1 - (1/2)^k)for a fair-coin retry loop and the bounded min/max cost of a staged-vs-direct recovery MDP.
Added — MDP probabilistic LTL: probability_ltl(..., moves=...) covers F / G / GF / FG, max AND min¶
probability_ltlgains a full MDP path (passmoves=as inreach_probability_mdp): the OPTIMAL probability over all schedulers that a run satisfies the formula,optimize="max"or"min", for the whole F / G / GF / FG fragment.GF p(Büchi) max = max reachability to a MAXIMAL END COMPONENT containing apstate (new_maximal_end_components, SCC-refinement; an absorbing state is its own MEC);FG p(co-Büchi) max = max reachability to the region the scheduler can stay inpforever (new_stay_within, a greatest fixpoint);F/Gare max/min reachability and its complement; everymindirection is the standard temporal dual (GF-min = 1 - FG-max(¬p), etc.). All exact, NO linear programming (that is only for the long-run AVERAGE reward, still deferred). Corpus pins the full matrix on one model: a scheduler that can steer givesmax = 1/min = 0for F/GF/FG (andG ¬Z); a probabilistic coin givesmax = min = 1/2.- Refactored
reach_probability_mdp's move-building into a shared_mdp_movesso the DTMC-as-MDP construction cannot drift between it andprobability_ltl. (This supersedes the max-only GF/F noted for the previous unreleased version.)
Added — examples/arbiter_fairness.py: fair LTL + action-sequence liveness + probability_ltl, worked¶
- A worked example for the three most recent temporal/probabilistic capabilities, which had no example: an
arbiter that must not starve a client. "Every
req_Ais eventually followed by aserve_A" is expressed over ACTIONS viawith_last_action+action_taken, then checked three ways —check_ltl(FAILS unfair: the arbiter can stall or favour B),check_ltl(..., fair=/fair_strong=)(HOLDS under weak OR strong fairness onserve_A), andprobability_ltlon the uniform-random DTMC (P(GF serve_A) = 1). The example asserts the exact verdicts and a subprocess test pins them;docs/examples/arbiter_fairness.mdrenders the scenario under a new "Temporal logic" nav section. No new library code.
Added — fixed_points_of: graph-first fixed_points over a pre-built graph (completes #31)¶
fixed_points_of(graph)computes the per-initial structural fixed points (the distinct no-outgoing-edge terminals reachable from each initial) over an ALREADY-exploredGraph, instead of re-exploring from each initial — the graph-first counterpart tofixed_points, ascheck_ofis tocheck, so several analyses can share oneexplore. Returns the sameFixedPoints(with.confluent) and carries the graph'struncated. Corpus pins that it agrees withfixed_pointson a confluent latch and a non-confluent race. This is the last of #31's graph-first variants (check_of/check_liveness_ofalready shipped).
Added — probability_ltl: exact probability a DTMC run satisfies an LTL formula (reducible fragment)¶
probability_ltl(model, formula, weight=...)returns the exact probability (aFraction) that a random run of the DTMC satisfiesformula, for the shapes with a direct characterisation:F p= reachability ofp;G p=1 - P(F ¬p);GF p= probability of absorption into a bottom SCC that CONTAINS apstate;FG p= absorption into a bottom SCC ENTIRELY insidep. Reuses the reachability solve and the BSCC decomposition (_bscc_reach_sum) and the reducible-fragment matcher fromcheck_ltl. Any other formula (Until,Next, leads-to, nested/disjoined temporal operators) raisesNotImplementedError: general probabilistic LTL needs a deterministic automaton for the formula (a Rabin/Safra construction), a larger build. Corpus separates GF from FG on a{A,B}cycle where onlyAis the target:P(GF A) = 1/2,P(FG A) = 0,P(F A) = 1/2.
Added — fair LTL on the general engine: check_ltl(..., fair=/fair_strong=) composes fairness¶
check_ltlnow honoursfair/fair_strong(weak / strong fairness by action name) for ANY formula, not only the reducible F / GF / G(a -> F b) forms — completing the LTL layer's last deferral (it previously RAISED on the engine path). Done by reduction:model |=_fair phiiffmodel |= (fairness -> phi), with the fairness assumption encoded as an LTL premise over thewith_last_actionmodel (wheretaken(a)andenabled(a)are state atoms),philifted onto the augmented state, checked by the existing Büchi engine, and the counterexample mapped back to the model's own states. Weak fairness isGF !enabled(a) | GF taken(a), strong isFG !enabled(a) | GF taken(a). An unknown fair action name raisesValueError.- Corpus pins an
Untilthat FAILS unfair (an idle-forever run) and HOLDS under weak OR strong fairness on the "ours" action, with the counterexample mapped back to the original states.LTL_NOTES.mdmoves fairness from deferred to shipped and records the terminal-stutter soundness argument.
Added — musil.history.with_last_action: temporal properties over the action sequence¶
with_last_action(model)augments each state toLastAction(state, action)— the original state plus the name of the action that produced it (Noneat an initial state) — socheck_ltl/check_livenesscan express properties over WHICH action was taken: "everyrequestis eventually followed by arespond" asG(action_taken('request') -> F action_taken('respond')). A state-only predicate cannot say "requestwas just taken", and a safety-only edge invariant cannot make a liveness claim across a run. Every model field is lifted faithfully (initial states getaction=None; actions keep theirname/fairtag and record themselves on apply; invariants/terminal/edge_invariants/footprint read.state;actions_fnis lifted).action_taken(name)is the "last action wasname" predicate. Pure stdlib.- Corpus pins a request/respond protocol:
G(respond -> F reset)holds,G(request -> F respond)fails (the timeout loop can starve a response without fairness), and the safety verdict is preserved by the augmentation.LTL_NOTES.mdrecords that this is the substrate for the deferred action-fairness engine (the weak/strong premises become plain LTL overaction_taken/enabledatoms on the augmented model).
Fixed — probabilistic weights: reject a negative weight instead of returning a bogus probability¶
- A negative
weightsilently produced a wrong answer: the weights are normalised into a probability distribution, so a negative one yields a negative or>1"probability", or a spurious0when the weights sum to zero (e.g.reach_probability(..., weight=w)returned0with no error). Every entry point that takes aweight—reach_probability,reach_probability_mdp,expected_reward,expected_reward_mdp,long_run_probability— now raisesValueErroron a negative weight, via a shared_weight_ofguard at the single normalization point. Corpus pins that all five reject a negative weight.
Added — examples/availability.py: the steady-state / until / MDP-cost probabilistic answers, worked¶
- A worked example for the three most recently shipped probabilistic capabilities, which had no example
(closing the "shipped features go unadopted" gap directly):
long_run_probability(steady-state availability of a service that cycles HEALTHY/DEGRADED/FAILED forever —π(healthy) = 4/7),reach_probability(..., avoid=...)(the until-probability of FAILING before recovering — 1/2, vs plain reachability 1), andexpected_reward_mdpmin/max (a STAGED vs DIRECT recovery strategy — min cost 2, max 3). Every answer is an exactFractionmatching a hand-computed closed form; the example asserts them and a subprocess test pins them.docs/examples/availability.mdrenders the scenario. No new library code.
Added — constrained (until) reachability: avoid= on reach_probability / reach_probability_mdp¶
- The exact probability of the until property
P(¬avoid U target)— reaching the target WITHOUT first entering anavoidstate — on both the DTMC (reach_probability) and MDP (reach_probability_mdp, min/max) functions, unbounded andsteps=k. Reuses the existing 0-sink mechanism: anavoidstate that is not itself a target becomes an absorbing failure (value 0), which_dtmc_values/_bounded_values/_s0_minalready resolve correctly;targettakes precedence. Corpus pins a DTMC whereavoiddrops reachability from 1 to 1/2 (a state that reaches the target a.s. by bouncing, but only directly with probability 1/2 while avoiding a failure state) and an MDP whereavoidseparates the schedulers (min 1 -> 1/2, max stays 1). No dependency.
Added — long_run_probability: exact DTMC long-run / steady-state fraction of time¶
- The exact fraction of time a DTMC spends in a target set in the limit (PRISM's steady-state / S operator), a
Fraction. A finite DTMC is absorbed almost surely into a bottom SCC (BSCC); inside each BSCC the long-run fraction of time in a state is its stationary probability, so the answer issum_B reach(B) * sum_{s in B, target(s)} pi_B(s). Reuses the proventarjanfor the BSCC decomposition and the exact_dtmc_valuesfor reachability to each BSCC; the stationary distribution is an exact rational solve (_stationary:pi P = pi,sum pi = 1, Gauss-Jordan). Equals the time average even for a PERIODIC BSCC (the Cesàro limit, whereP^ndoes not converge); transient states carry zero long-run measure; a deadlock/absorbing state is its own BSCC. Corpus pins the periodic 2-cycle (1/2), a biased chain (1/3), and a transient split into two absorbing BSCCs (1/2;1for the whole absorbing set). No dependency.
Added — expected_reward_mdp(..., optimize="max"): MDP maximal expected reward (stochastic longest path)¶
- The maximal expected accumulated cost to first reach the target over all schedulers, exact over the
rationals. Finite only when EVERY scheduler is proper (else a scheduler loops away from the target and the
max is infinite), so it guards on MIN reachability 1 from the initial state — reusing the existing
_s0_min - minimizing
_reach_policy_iteration— which implies every reachable state is proper. Any policy is then a proper seed, and maximizing policy iteration (each switch strictly raises a bounded, monotone expected cost) converges to the optimum (Bertsekas & Tsitsiklis SSP, all-proper case), sharing the min path's exact evaluation. Raises when the guard fails (some scheduler avoids the target with positive probability, max is infinite) oroptimizeis not"min"/"max". Corpus pins a fast-vs-slow MDP (min = 1,max = 2steps) and the infinite guard (a self-loopstallmove drops min reachability below 1, so max raises, min stays 1). PROBABILISTIC_NOTES.mdmoves MDP max expected reward from Deferred to Shipped; only zero/negative-cost SSP and probabilistic-LTL/steady-state remain deferred.
Added — the composed-components scaling benchmark + the symmetry recipe (#37)¶
examples/cosim_master_scaling.py: a generic co-simulation master over N interchangeable components (each taking up tokindependent micro-steps per macro step, with rollback) — the named partial-order-reduction benchmark, run as a MEASUREMENT. The curve (k=3, n=2..5): full interleaving(k+1)ⁿ= 16, 64, 256, 1024; under sorting symmetryC(n+k, n)= 10, 20, 35, 56 (up to 18.3× at n=5). The full search crosses the defaultmax_states=1_000_000around n=10; the symmetric search is still 286 states there. The measured answer: for anonymous interchangeable components,canonical_by_sorting— not POR — makes the workload scale, which is why POR stays deferred behind this curve (REDUCTION_NOTES.md). No new library code.docs/examples/cosim_scaling.md: the "interchangeable components in a composed model"canonicalize=recipe as a documented lever (build the slice accessor pair, hand it tocanonical_by_sorting, and validate withsymmetry_reduction_soundfirst), with the curve and the soundness caveats. The example's own asserts pin the closed-form counts and runsymmetry_reduction_soundon each instance;tests/test_example_cosim_scaling.pyruns it as a subprocess and pins the published curve.
Fixed — check_ltl: refuse fairness on the general engine instead of silently dropping it¶
fair/fair_strongare honoured only for the reducible LTL forms (F / GF / G(a -> F b)), which route throughcheck_liveness. The general Büchi engine (Until / Release / Next / nesting) did not compose fairness and silently IGNORED afair/fair_strongargument — returning a verdict, and possibly a counterexample lasso, that a fair scheduler would exclude.check_ltlnow RAISESNotImplementedErrorwhen a non-emptyfair/fair_strongis passed with an irreducible formula, keeping the surface sound; an emptyfair=()is no constraint and still runs. The liveness patterns are unaffected (their formulas always reduce).LTL_NOTES.mdrecords the deferral: composing fairness in the engine needs action-labelled atoms in the product (the premise(GF enabled_a -> GF taken_a) -> φis over action occurrences), the next LTL increment.
Changed — BFS-loop unification (roadmap 1b): share successors on the edge-invariant path¶
- On the edge-invariant path
checkandcheck_allcomputed every successor TWICE per state: once for the edge-invariant scan, again inside_expand. They now compute the(name, canon(next))list ONCE and feed it to a new_extend_frontierprimitive that does the frontier bookkeeping +max_statestruncation from an already-computed successor list. Fewer applies on that path; verdict, trace, and truncation are identical (guarded by the existing edge-invariant +check_ofdifferential cases and a new corpus case pinning truncation-under-edge-invariants and same-reachable-set-as-explore). The soundness-critical successor computation stays the single_successors. src/musil/BFS_UNIFICATION_NOTES.mdrecords the (1b) design decision:check/check_allare unified at the primitive level (_successors,_seed_frontier,_expand,_extend_frontier);exploreshares_successorsbut keeps its own edge-recording loop;profilestays inlined so it can time each primitive. A single driver loop for all four is deliberately NOT done: it would regresscheck's check-before-expand early-exit or deleteprofile's per-primitive measurement.
Added — musil.citations: model references must not dangle (#34)¶
- A good practice is for production code to CITE the model that justifies a non-obvious design
(
... — test_mesh_membership_musil); it fails silently when the pointer rots (code cites a model file that no longer exists anywhere).find_citations(source) -> list[Citation]locates the references with a regex (so it sees comments and prose, not only identifiers), anddangling_citations(source, known)reports the ones whose model name is not inknown— typicallyknown_models(test_tree), the stems of thetest_*_musil.pyfiles that exist. Pure stdlib (re+pathlib); never imports or runs the scanned code. The reference shape (test_*_musil) is the default;pattern=overrides it. python -m musil citations <path>... [--pattern P]runs it as a gate: it resolves every citation under the given paths against the model files that exist there and exits 1 iff one dangles (0 when all resolve), printingfile:line: dangling model citation: <name>. A companion tomusil doctoron the same CLI.tests/test_citations.pypins finding, word-boundary handling, dangling-vs-known, filesystem resolution, and a custom pattern;tests/test_cli.pypins the gate's exit codes (1 on a dangling ref, 0 when clean).
Added — examples/distributed_kit.py: the reconciler + fault kit, worked end to end¶
- A worked example of the distributed-systems builders (
reconciler,crash_actions) that most models reach for, addressing the recurring "shipped features go unadopted" finding directly. Two verifiable halves: (1) a controller whose[]<>convergedHOLDS under a fair reconcile even as an unfairglitchfault keeps resetting it, and FAILS withfair=()(the fault can starve reconcile forever) — showing the fair-ours / unfair-faults discipline is what carries convergence; (2) a 3-node cluster whose quorum invariant survives a crashbudget=1but breaks atbudget=2. The example asserts both verdicts; a subprocess test pins them. No new library code.
Added — musil.doctor.scan: spot hand-rolled patterns a shipped API already covers (#36)¶
scan(source) -> list[Finding]reads Python source with the standard-libraryast(dependency-free, and it never imports or runs the code) and returns advisoryFindings — file positions plus the API to reach for — closing the loop on the recurring finding that shipped features go unadopted. Detected today:assert X.ok, <msg>->assert_ok(X)(the message is the tell that distinguishes musil's idiom from a bare truthiness assert, e.g.requests'response.ok);assert not X.ok->assert_violates(X, ...); and[s.action for s in X.trace ...]->X.action_names. Advisory, not a gate.tests/test_doctor.pypins the matchers on known snippets (source -> expected finding), including that a bareassert x.okand code already using the shipped APIs are left alone.
Added — musil doctor <path> CLI + a musil console command¶
python -m musil doctor <path>scans a file or directory (recursively) withmusil.doctor.scanand printsfile:line:col: pattern: suggestion; unreadable / un-parseable files are skipped and it always exits 0 (advisory, not a gate). This is the runnable front end for thescancore — the thing consumers actually invoke — completing #36's CLI half.- The package now installs a
musilconsole entry point ([project.scripts]), somusil doctor <path>works directly, and the existingpython -m musil <model.py> [--dot]model-check path is unchanged (thedoctorsubcommand is a backward-compatible addition).tests/test_cli.pycovers both paths.
Added — portfolio_coverage + assert_covers: suite-level coverage of a declared table (#35)¶
portfolio_coverage(models_or_graphs, get=, declared=)answers the question a single-modelcoveragecannot: how much of a declared external state machine does the WHOLE portfolio exercise? It reads the transitions of a state FIELD across each edge (get(before) -> get(after), so action naming is irrelevant), unions them across every model, and diffs against thedeclaredtable — returning aPortfolioCoveragewith covered / dead / undeclared edges, covered / dead field values, and an exactratio(Fraction). AGraphis used as-is; aModelis explored.assert_covers(portfolio, declared, get=, ratio=, blind_spots=)is the ratchet gate that fixes the numerator-only "N transitions covered" test which let one suite rot 67% -> 56% while staying green. It asserts: (1) no undeclared transition (the portfolio never exercises an edge the table forbids); (2) the dead edges are EXACTLY the namedblind_spots(a new dead edge is a regression; a covered blind spot must be removed — the set can only shrink); (3) the coverage ratio meets the floor.- Corpus pins a two-model portfolio over a 4-edge table: the union covers all four (ratio 1), one model alone covers half, and the gate rejects both a new unacknowledged dead edge and an undeclared transition.
Added — musil.reconciler: the controller/reconciler shape as a builder (#23)¶
reconciler(get_desired=, get_actual=, set_actual=)returns(converged, reconcile)for the single most common shape in modern infrastructure (operators, sync engines, cache maintainers): adesiredfield the environment sets, anactualfield the controller drives toward it, goal[]<>converged.convergedisactual == desired(feed tocheck_liveness(everywhere=True));reconcileis a fairAction(taggedfair="weak") that setsactualtodesiredwhile they differ — the "ours" side of the fair-ours / unfair-faults discipline, so a liveness proof may assume the controller keeps reconciling but not that a fault heals itself. Composes with the fault factories (crash_actions, channel loss) as unfair actions orcheck_openenvironments. Generic over both the state and the value type.- Corpus pins a reconciler with a competing UNFAIR
idleself-loop:[]<>convergedHOLDS because reconcile is weak-fair by construction, and FAILS withfair=()(idle can starve reconcile forever) — showing the fair tag is exactly what carries convergence. The chosen-design / negative-control TEST HARNESS the issue also mentions is a larger, separate piece and is deferred.
Added — richer transition builders: effect / also, and replicated_transition_actions (#26)¶
transition_actionsgains two keyword arguments:effect(state, src, dst)runs after the status change and returns the state with side effects on OTHER fields applied (so a transition is not limited to swapping the status field), andalso(state)is an extra guard ANDed with "currently insrc" for a cross-cutting precondition the table itself does not encode. Backward-compatible (both default toNone).replicated_transition_actions(table, instances, get=, set_status=, effect=, also=)runs N copies of one transition table — one machine per instance — over a FLAT state, for a system of interchangeable entities (entries, replicas, jobs) that all obey the same lifecycle.instancesis anintN or an iterable of keys; the projections are per-instance (get(s, key)/set_status(s, key, dst)/effect(s, key, src, dst)/also(s, key)). Unlikecompose(which forces aCompositetuple state), the state stays a flat dataclass, so cross-instance invariants (a + b == 0) remain expressible.- Retires the hand-written triple loop +
_ENTRY_FIELDS/ per-instance index helpers that multi-entity workflow models write while citing the zero-drift docs. Corpus pins a two-entry ledger: 2 entries × 2 edges = 4 actions, 9 reachable states, and theeffectkeepsbalance == count of posted entries(a cross-instance invariant). Gap #4 of the issue (the illegal-firing ghost flag) is already covered by the edge invariants added earlier. assert_covers_table(model, table, get=)checks the zero-drift claim mechanically, replacing the hand-rolled action-name test: over the reachable space, every step that changes the status must go to adstdeclared intable[src], so the model can never make a transition the table forbids. It raisesAssertionErrorlisting any undeclaredsrc -> dstfound. It checks CONFORMANCE (no forbidden transition), not completeness — a declared edge may be legitimately unreachable or held off by analsoguard, so "every table edge is exercised" is left to the caller (declared_states/terminal_stateshelp). Corpus pins that a table-built ledger conforms and that a hand-writtendraft -> closedskip is caught.
Added — musil.faults: explicit-state crash/revive and partition/heal faults (#29)¶
- New module
musil.faultswith two action factories for the explicit-state path (check/explore/check_open) where most models live — retiring the byte-identical per-node fault edges distributed models hand-write. Same lens style (get/put) asmusil.channels. crash_actions(nodes, get_up=, set_up=, budget=, revive=)builds crash (and optional revive)Actions over a node-liveness lens.budgetbounds the number of SIMULTANEOUS failures (a crash fires only when fewer thanbudgetnodes are already down);revive=Trueadds heal edges for reconvergence testing.partition_actions(splits, get_reach=, set_reach=, healed=)builds partition/healActions over a reachability lens: each caller-supplied split installs from the HEALED state (partitions do not stack), and a single heal restoreshealed— the heal-able network-partition fault.- Corpus pins a 3-node quorum mesh for each:
crash_actionswithbudget=1holds quorum (≥2 of 3 up) in 4 states andbudget=2drops below it via twofault:crash:*edges;partition_actionswith a{0}|{1,2}split holds quorum in 2 states and an all-singletons split breaks it via thefault:partition:0edge. - Note: the issue referenced a
cohort_faults(0.28) precedent, but no such helper exists in musil — both factories are designed fresh against thechannelsfactory convention (partition_actionsadds an explicithealed=for the full-reachability configuration).
Added — fairness by action tag + fair-name validation (#27)¶
Action(..., fair="weak" | "strong" | None)— an action can carry its own fairness. Whencheck_livenessis called with no explicitfair=/fair_strong=tuple (now defaulting toNone), it DERIVES the weak/strong sets from these tags, so the fair-ours / unfair-faults split lives on the action that knows what it is, instead of a parallel name list kept in sync by hand.- Soundness fix: an explicit
fair/fair_strongname that matches no action now raisesValueErrorinstead of being silently ignored. A silent no-match fails in the unsound direction (an action that should be fair is treated as unfair, or astartswith-style filter accidentally grants fairness and a real violation disappears). Tags and validation apply to a staticactionslist; withactions_fnnames are passed explicitly and used unchecked. Backward-compatible: models without tags and callers passing valid names are unchanged (all 359 prior tests pass). - Corpus pins that deriving from a
fair="strong"tag equals passing the name explicitly, that an explicit empty set overrides the tags (no fairness), and that a bad name raises. - The tags flow through the
musil.patternssugar (eventually/recurs/responds) andcheck_ltltoo: theirfair/fair_strongnow default toNone(forwarded tocheck_liveness), so a tagged model gets its fairness whether you callcheck_livenessdirectly or via a pattern.
Added — edge (transition) invariants: predicates over (before, action, after) (#25)¶
Model.edge_invariants={name: fn(before, action_name, after) -> bool | str}— invariants about the STEP, not the state, for the case where the bad state is legitimately reachable but a particular EDGE into it is illegal (TLA+'s action formula). Checked on every explored edge bycheckandcheck_of; the first violation returns aResultwithkind="edge-invariant", the broken name ininvariant, and the offending transition as the last step of the shortest trace. Off by default (empty), and it recomputes successors only when present, so models without edge invariants pay nothing.never(action, when=...)— the direct "forbid this transition" edge invariant, replacing the ghost-flag pattern (a state field set purely to turn "an illegal action fired" into a state predicate, which doubles the state space it lives in).- Corpus pins a document lifecycle where
publishedis legitimately reachable (so a state-onlycheckis green) yet aforce_publishedge reaches it illegally — the edge invariant catches exactly that edge, andcheck_ofreturns the identical verdict and trace. check_all(the all-violations audit) now also checks edge invariants, bucketed in their own namespace so an edge and a state invariant of the same name never collide; it reports state and edge violations together in one pass. (It previously ignored them silently — an unsound-direction gap in an audit tool.)
Added — check_liveness_of: liveness over a pre-built graph (#31)¶
check_liveness_of(graph, goal=..., ...)runs the liveness analysis over an already-exploredGraphinstead of re-exploring — the graph-first counterpart tocheck_liveness, same properties/verdict/lasso.check_livenesswas refactored to be exactlycheck_liveness_of(explore(model, max_states=...), ...), so there is a single analysis path (no divergence risk) and the graph can be shared withcheck_of/coverage_of/invariant_coverage_of. Corpus pins that the two entries give the identicalLivenessResulton a passing case and on a strong-fairness sub-SCC counterexample.- With this,
check_of+check_liveness_ofcover the safety and liveness halves of #31;fixed_points_ofremains open pending the per-initial-state semantics decision (fixed_pointsexplores each initial separately, so a single merged graph would give a narrower answer).
Added — check_of: the safety check over a pre-built graph (#31, partial)¶
check_of(graph, model)runs the safety verdict over an already-exploredGraphinstead of re-exploring — the graph-first counterpart tocheck, ascoverage_ofis tocoverage— so several analyses (coverage, safety, ...) can share oneexplore. Returns the sameResult: the first invariant violation or deadlock in breadth-first order (shortest trace), else OK.- A differential test in the corpus pins that
check_of(explore(m), m)gives the SAME verdict AND the same shortest trace ascheck(m)on every corpus model — the guard against the two code paths diverging (they agree exactly today, trace included). Deadlock detection needs a complete graph: on a truncated one a cut-off frontier state looks like a sink, socheck_ofreports invariant violations there but not deadlocks, and a clean result carriestruncated=True. check_liveness_ofandfixed_points_of(the rest of #31) are follow-ups:check_livenessfactors cleanly (it already explores-then-analyses), whilefixed_pointsexplores each initial state separately, so a single-graph variant has a narrower per-initial semantics to settle first.
Added — negative-side assertion helpers: assert_violates, .action_names, .witness (#32)¶
assert_violates(result, kind=..., invariant=..., via=[...], where=lambda s: ...)— the red-side counterpart toassert_ok: asserts a check FOUND a violation, optionally pinning the verdict kind, the broken invariant, actions that must appear in the counterexample, and a predicate on the offending state, rendering the full counterexample on any mismatch. Works onResultandLivenessResultalike.Result.action_names/LivenessResult.action_names— the action names along the counterexample, excluding the synthetic<init>step (forLivenessResult, the stem then the cycle).Result.witness/LivenessResult.witness— the offending state (the last state of the trace / stem). These replace the hand-rolled[step.action for step in result.trace ...]andresult.trace[-1].stateidioms.
Changed — assert_ok rejects a truncated green by default (truncated="fail")¶
assert_ok(result)now FAILS when the result passed but the sweep TRUNCATED atmax_states— a truncated green proves nothing about the unexplored part, so treating it as a proof was a quiet soundness hole in test suites. Passtruncated="ok"to accept an inconclusive pass. This is a behaviour change: a test that assertedassert_okon a truncated result now fails (correctly); raisemax_statesor opt out explicitly. Consistent with musil's exact-only stance (a cleancheckis a proof, a truncated one is not).
Added — witness API: path_to, witness, states_where (positive witnesses over a graph)¶
- musil could produce counterexamples (paths to a bad state) but had no first-class way to get a positive
WITNESS — a path to a good state.
reachesanswers "can it get there?" as a yes/no over the whole reachable set; these return the concrete path (GitLab #30): path_to(graph, state)— the shortest path (aSteptuple, render withformat_trace) from an initial state to a specific reachablestate, orNoneif unreachable.witness(graph, predicate)— the shortest path to SOME reachable state satisfyingpredicate, orNone(the concrete-path counterpart toreaches).states_where(graph, predicate)— the reachable states satisfyingpredicate, in exploration order (solution_cardinalitycounts these; this returns them).- All three operate on a pre-built
Graph, via a shared forward-BFS shortest-path helper, so no re-exploration. Corpus pins the exact path lengths on the counter model and theNonecases.
Fixed — check_liveness: strong fairness missed sub-SCC counterexamples (soundness)¶
check_liveness(..., fair_strong=(...))could returnok=Truewhen a fair counterexample existed — a false OK, the unsound direction. Fairness was judged on the MAXIMAL SCC: a strongly-fair action enabled anywhere in the SCC but never taken there ruled the whole SCC out, even when a cycle confined to a sub-SCC never enables that action and so carries no fairness obligation._find_fair_sccnow applies the standard Emerson-Lei refinement — remove the states that enable an unsatisfied strongly-fair action and re-decompose the remainder, recursing to a fixpoint — so a fair cycle in a sub-SCC is found.- Regression pinned in the corpus (issue #22): a model whose maximal SCC contains a strongly-fair action enabled only at one state, with a sub-cycle that avoids it, now correctly reports the fair counterexample (cycle confined to the sub-SCC); a companion model where the fair action is enabled throughout the only cycle still correctly reports the property holds (the fix does not over-report). Weak fairness is unchanged (enabled-everywhere persists under any subset, so it needs no refinement).
Added — examples/coverage_vacuity.py: when a green check is meaningless¶
- A worked example of
coverage/invariant_coverage(GitLab #2 item 1, "cheapest, highest signal"): a tiny account passescheckcleanly, yetcoverageflags a dead action (thaw, guarded on a status nothing sets) andinvariant_coverageflags a vacuously-true invariant (status-known, which no action can break) while confirmingnon-negative-balanceis genuinely load-bearing (the withdraw guard protects it). Shows that OK means "no invariant broke," not "the behaviour was exercised" or "the invariants caught anything." Subprocess test pins the reports; the example asserts them itself. Generic account domain, no new library code.
Added — examples/ltl_properties.py: the LTL layer, worked end to end¶
- The first worked example of
check_ltl: a traffic light with six temporal properties that hold (safetyG known,F green, recurrenceG F red, responseG(yellow -> F red),X green,known U green), spanning the reducible fragment and the general Büchi engine (Next,Until). The last property,F G green("eventually green forever"), is FALSE and returns a counterexample as an infinite run — a lasso with the--- loops forever ---marker showing the light never stabilises. A subprocess test pins the verdicts and the marker; the example asserts them itself. Generic traffic-light domain, no new library code.
Added — examples/lossy_retry.py: the probabilistic layer, worked end to end¶
- The first worked example of
musil.probabilistic: a message over a lossy link (each attempt delivers with probability 1/2, else retries). It showsreach_probability(eventual delivery = 1), the boundedreach_probability(..., steps=k)geometric law1-(1/2)^k,expected_reward(expected attempts1/p = 2), andexpected_reward_mdp(when the sender may pick a fast or careful link, the minimal expected cost over all strategies,2, with the fast link chosen) — all as exactfractions.Fraction. A subprocess test pins the printed closed forms, and the example asserts them itself. Generic networking domain, no new library code.
Added — LTL counterexamples mark where the infinite run loops¶
Resultgains aloop: int | Nonefield: for an infinite-run (LTL lasso) counterexample it is the index intracewhere the cycle begins — the state reached, then repeated forever — andNonefor a finite (invariant/deadlock) counterexample.check_ltl's general engine now sets it (accepting_lassoreturns the stem length alongside the lasso), andformat_traceemits a--- loops forever ---marker after that state (matching the existingLivenessResultrendering), so a liveness counterexample shows which suffix repeats.Result.__str__also renders a properLTL VIOLATED: <formula>header forkind="ltl".- The field is defaulted, so every existing
Resultconstruction is unchanged. Corpus pins that the loop index is in range and that the cycle closes back to the marked entry state.
Fixed — musil.ltl: document the general-engine counterexample trace (and pin it)¶
- The
check_ltldocstring and the docs LTL section claimed the general Büchi engine returns "no counterexample trace yet" / that out-of-fragment formulas "raiseNotImplementedError". Both were stale: the engine has for a while returned a real counterexample — the accepting lasso of theNot(formula)automaton mapped back to model states (<init> -> stem -> cycle) — asResult.trace, and out-of-fragment formulas are decided, not rejected. Corrected the docstring anddocs/api.md. - Corpus now pins the guarantee: for a failing out-of-fragment formula (
Next(red)on the traffic light),check_ltlreturns a non-empty trace that is a GENUINE run of the model — it starts at the initial state and every consecutive pair is a real model edge. So the counterexample can never silently regress to empty or to a non-run.
Changed — musil.patterns: the liveness patterns are now sugar over check_ltl¶
eventually,recurs, andrespondsnow build the corresponding LTL formula (Eventually(p),Always(Eventually(p)),Always(Implies(trigger, Eventually(response)))) and callcheck_ltl, instead of callingcheck_livenessdirectly.check_ltlalready owns the one reduction of that temporal fragment ontocheck_liveness(with the samefair/fair_stronghandling), so the lowering now lives in a single place — fix or extend it there and every pattern follows — and a pattern is by construction the same verdict as its LTL formula. Behaviour and return types are unchanged (each still returns the sameLivenessResult); the existing pattern tests pass untouched, and a corpus entry pins the pattern-vs-check_ltlagreement on both a property that holds and one that does not.- The two safety patterns (
always,absent) stay directcheckcalls:G(state)is just an invariant, and they exposecanonicalize/keythatcheck_ltl's safety reduction does not forward, so routing them through it would drop those options for no gain. This is the "patterns re-expressed as sugar overcheck_ltl" roadmap item, done where it is lossless.
Added — musil.probabilistic: MDP min expected reward (stochastic shortest path)¶
expected_reward_mdp(model, target, moves=..., cost=...)returns the exact MINIMAL expected accumulated cost to reachtargetover all schedulers of an MDP (default unit cost = minimal expected steps).- The normally-hard piece, a proper initial policy for policy iteration, comes for free: the
max-reachability-optimal policy reaches the target almost surely from every state whose max reachability is
1 (a convex combination of values
<= 1equal to 1 forces every successor to 1), so it is proper there and seeds the iteration. The iteration minimises expected cost over the moves whose successors are all proper, each evaluation an exact rational solve ofE(s) = cost(s) + sum_s' P(s, s') E(s'). Strict improvement keeps the policy proper (an improper policy has infinite cost) and terminates at the optimum (Bertsekas & Tsitsiklis SSP). - Costs must be positive (default unit); it raises
ValueErrorwhen no scheduler reaches the target almost surely from the initial state (min expected cost infinite). MAX expected cost and zero/negative-cost cycles are deferred (reasons inPROBABILISTIC_NOTES.md). Refactored the reach policy iteration into a shared_reach_policy_iterationthat returns the optimal values AND policy (the SSP seed); guarded by the existing MDP reachability corpus. - Corpus pins the retry-until-success minimal expected attempts
1/p(a slower sure alternative does not lower it), plus tests for the cheaper-route choice, the safe-move restriction (avoid a non-reaching branch), the infinite-cost guard, and ignoring an improper self-loop.
Added — musil.probabilistic: DTMC expected reward (expected_reward)¶
expected_reward(model, target, cost=...)returns the exact expected accumulated cost to first reachtargetin a DTMC; with the default unitcostit is the expected number of steps. SolvesE(target)=0andE(s) = sum_s' P(s, s') (cost + E(s'))over the rationals (no floating point).- The expectation is finite only when the target is reached ALMOST SURELY, so it guards on
reach_probability == 1from the initial state and raisesValueErrorotherwise (infinite expectation). That guard is what makes the(I - P)system on the non-target states non-singular: every explored state is init-reachable, and if init reaches the target a.s. then so does every non-target state. - Corpus pins two known closed forms: the fair-walk absorption time
E = i(N-i)and the geometric mean1/pfor a coin loop. MDP expected reward (the stochastic shortest/longest path problem) is deferred with the reasons inPROBABILISTIC_NOTES.md(MAX is infinite unless all schedulers are proper; MIN needs a proper initial policy and zero-cost-cycle care).
Added — musil.probabilistic: step-bounded reachability (steps=k)¶
- Both
reach_probabilityandreach_probability_mdpnow take an optionalsteps=k: the exact probability of reachingtargetWITHINktransitions (the unbounded probability is still the default,steps=None). Computed by a finite backward induction (_bounded_values):V_0is the indicator of the target,V_i(s) = opt_move sum_s' P(s, s') V_{i-1}(s'). No fixed point, so it is exact by construction and needs noS0precomputation — a bounded horizon cannot stall.steps=0is the indicator of the target; a DTMC is the single-move case (the per-step optimisation is over one value). NegativestepsraisesValueError. - Corpus pins the geometric law: for a fair coin loop (win with prob 1/2 each step, else retry),
reach_probability(..., steps=k) == 1 - (1/2)^k, exactly, for everyk.
Added — musil.probabilistic: MDP min-reachability (exact, via S0min + policy iteration)¶
reach_probability_mdp(..., optimize="min")now returns the MINIMAL probability of eventually reachingtargetover all schedulers (it previously raisedNotImplementedError). Same exact policy iteration as the max case, minimizing, with one precomputation:_s0_minfinds the states from which a scheduler can avoid the target forever (the greatest set of non-target states in which every state has a move whose support stays inside the set, by iterative removal) and pins them to probability 0.- That precomputation is exactly what makes min exact: without it, min policy iteration stalls on a
self-loop (or a loop through a non-target end component) whose move-value equals its current value, so the
strict-improvement step never lowers it to the true 0. With
S0minpinned, no set of the remaining states can be closed under any policy, so every policy is proper and minimizing policy iteration converges to the unique exactxmin. No maximal-end-component collapse is needed —_dtmc_valuesalready zeroes unknowns a fixed policy cannot route to the target. SeePROBABILISTIC_NOTES.md. - Corpus pins three known verdicts: the two-gamble MDP (
xmin = 1/2, the worse coin, vsxmax = 2/3), a self-loop (xmin = 0,xmax = 1), and a two-state non-target end component (xmin = 0) — the case_s0_minexists for.
Added — musil.probabilistic: MDP max-reachability (exact policy iteration)¶
reach_probability_mdp(model, target, moves=..., optimize="max")adds NONDETERMINISTIC choice on top of the DTMC layer: a caller-suppliedmoves(state, enabled_actions)partitions each state's enabled actions into the moves a scheduler picks between, and the actions within a move form one probability distribution (viaweight). It returns the MAXIMAL probability of eventually reachingtargetover all schedulers.- Solved EXACTLY by policy iteration: each policy's value is an exact DTMC solve (
_dtmc_values, now shared withreach_probability), then every state switches to a strictly better move under those values, until the policy is stable. Termination is by monotonicity (each switch strictly increases the bounded values, so no policy repeats and the finite policy space is exhausted) — no floating-point convergence threshold. - Grouping every enabled action into a single move recovers
reach_probabilityexactly (a DTMC has no choice); pinned as a differential test against the gambler's-ruin walk. optimize="min"is added in the entry above (it needs theS0minprecomputation the max case does not).- Corpus pins a two-gamble MDP whose max value is hand-computable: move A is a fair coin (1/2), move B is
weighted 2:1 (2/3), so the max scheduler picks B and
reach_probability_mdpreturns exactly2/3.
Added — musil.probabilistic: exact DTMC reachability probability¶
reach_probability(model, target, weight=...)returns the EXACT probability of eventually reaching a target set, treating the reachable graph as a discrete-time Markov chain: each state's enabled actions carry a relativeweight(name, state)(default1, i.e. uniform) that normalises into a distribution over successors. The answer is afractions.Fraction— no floating-point error, no dependency (numpy stays an optional accelerator only).- The standard three-way split (Baier & Katoen, Principles of Model Checking, ch. 10): target states have
probability 1; states that cannot reach the target (found via
reaches) have probability 0; the rest solve the linear systemx_s = sum_s' P(s, s') x_s'by Gauss-Jordan elimination over the rationals. Truncated exploration raises rather than returning an inconclusive number. - Corpus pins the fair symmetric random walk (gambler's ruin):
reach_probabilityon 0..N equals the theory closed formi/Nfor every starti, a known-verdict oracle independent of musil. This layer is the base for the MDP reachability that follows.
Added — musil.por: partial-order reduction mechanism + soundness gate¶
- POR fires only an ample set of actions per state instead of every interleaving. This first layer ships the MECHANISM and the GATE (the risky automatic ample-set computation lands on top, guarded):
explore_por(model, ample=...)builds the reachable graph firing onlyample(state, enabled) -> subsetat each state (ample = lambda state, enabled: enabledis exactlyexplore).por_sound(model, ample)validates that an ample choice is safe before it is trusted: the reduced reachable set must be a subset of the full one, and the SAME invariants must be violable in both — an ample set that drops the only path to a bug makes these disagree. Same discipline assymmetry_reduction_sound/footprint_sound; certifies SAFETY preservation (deadlock/liveness need the extra proviso, called out and left to a later step).independent(a, b, state)is the concrete per-state independence test (both enabled, neither disables the other, and they commute) the ample-set computation will rest on — testable concretely because musil'senabled/applyare pure. Corpus pins that the trivial ample matchesexploreand is sound.
Added — musil.ltl: an LTL formula AST + check_ltl (first layer)¶
- An LTL formula as pure data —
Atom,Not,And,Or,Implies,Next,Eventually,Always,Until,Release,WeakUntil— andcheck_ltl(model, formula), the start of a full temporal-property layer (issue #2, item 2). This first cut checks the fragment that reduces SOUNDLY onto the existing proven primitives:Always(state)-> a safety invariant,Eventually(state)-><>P,Always(Eventually(state))->[]<>P, andAlways(Implies(a, Eventually(b)))-> the response (leads-to) property, wherestateis any boolean combination ofAtoms. - Anything outside that fragment (
Until,Release,Next, arbitrary nesting) raisesNotImplementedErrorrather than returning a guessed verdict — the general engine (a Büchi automaton for the negated formula, its product with the reachable graph, emptiness via nested-DFS) lands incrementally on top of this same AST, at which point the fixed liveness forms andmusil.patternsbecome sugar over it. Corpus entries pin the reductions against the traffic light's known verdicts. check_ltlpushes negations to the literals first (semantics-preserving LTL dualities:!F = G !,!G = F !,!X = X !, De Morgan,!(a U b) = !a R !b), so negated temporal forms reduce too — e.g.!F pbecomes the invariantG !p,!G pbecomes<> !p.Impliesis kept unexpanded so the response shape survives. This is also the negation-normal-form preprocessing the general engine needs.- A top-level conjunction splits:
model |= (a & b)iffmodel |= aandmodel |= b, so conjoined specs likeG(safety) & G F(progress)reduce (each conjunct independently; a failing conjunct's result is returned, an irreducible conjunct propagates the refusal).LTL_NOTES.mdrecords the plan for the general Büchi + nested-DFS engine that will replace the remainingNotImplementedErrors. Buchi: a Büchi automaton over model states (guards are predicates on the model state), withhas_accepting_run(graph)— the emptiness check at the heart of LTL model checking: the product of the reachable graph and the automaton has an accepting run iff some reachable non-trivial SCC contains an accepting automaton state. Reuses musil's proventarjan/has_internal_edge(the SCC machinerycheck_livenessalready uses, now package-internal). This is the sound "back half" of the engine, built and validated first (hand-built automata forF(!green),F(!known),G(!green)on the traffic light); the GPVWNot(formula) -> Buchifront half and the wiring intocheck_ltlfollow.GeneralizedBuchi: several acceptance sets (a run must visit EACH infinitely often) — the exact shape the GPVW construction produces (one acceptance set perUntil), so the engine runs it directly without degeneralizing.has_accepting_runfinds a reachable non-trivial SCC that intersects every acceptance set (empty sets accept any infinite run). Product construction is now the shared_productused by bothBuchiandGeneralizedBuchi. Validated on hand-built generalized automata over the traffic light.GeneralizedBuchi.from_ltl(formula): the GPVW tableau construction — an LTL formula (put in NNF first) becomes the automaton accepting exactly its satisfying model runs, one acceptance set perUntil/Eventuallysubformula. This is the general LTL engine's front half. Validated by DIFFERENTIAL testing: for the reducible fragment,from_ltl(Not(phi)).has_accepting_run(explore(m))agrees with the trustedcheck_ltlreductions across many formula/model combinations, plus hand-verifiedUntil/Next/ nested cases. Not yet wired intocheck_ltl(the wiring step adds terminal-state stuttering, seeLTL_NOTES.md); building and trusting the front half first.check_ltlnow checks ARBITRARY LTL: the reducible shapes still fast-path tocheck/check_liveness(with traces), and everything else —Next,Until,Release,WeakUntil, arbitrary nesting, bare state formulas — falls through to the general engine (GeneralizedBuchi.from_ltl(Not(phi))+has_accepting_run). Terminal/deadlock states are stutter-extended so their finite runs get the right infinite-run semantics. The engine path returns aResult(kind="ltl"on violation); a counterexample lasso from the engine is still to come. Corpus pins general-LTL verdicts (X,U) on the traffic light.GeneralizedBuchi.accepting_lasso(graph)extracts a concrete counterexample from an accepting run: a stem to the accepting SCC plus a cycle visiting every acceptance set, mapped back to model states.check_ltl's engine path now returns it as theResult.trace(starting<init>, then the stem, then one iteration of the repeating cycle), so a general-LTL violation carries a real witness, not just a verdict.
Internal — check/check_all/explore share one successor kernel¶
check,check_all, andexplorenow compute their canonical enabled successors through one lazy generator,_successors(the single placecanon(action.apply(state))happens) — plus_seed_frontier/_expandshared bycheck/check_all. This is the exact spot that drifted in the 089aed0 bug (successors canonicalized in one path but not the other); it now lives in one place so it cannot diverge again. No behavior change: the known-verdict corpus (tests/test_corpus.py) pins identical reachable-state counts and verdicts across the refactor.check/check_all/exploreshare the kernel;profilekeeps its own instrumented loop by design (it timesenabled/apply/canonicalizeseparately, which the bundled kernel cannot), and the corpus now pins its full-sweep reachable count tocheck/exploreso it cannot silently drift either. Move 1b (BFS unification) complete.
Docs — genericize examples to invented / commonly-known systems¶
- Doc examples and docstrings now use invented or widely-known systems only (a generic control plane,
add_filter{field='status'}), so nothing in the published docs or rendered API reference reflects any particular downstream deployment.
Added — musil.environments.distributed: stateful storage/state fault contracts¶
- Two parameterized
EnvironmentSpecfactories for the fault classes that dominate a stateful distributed system's incidents (issue #21, scope confirmed on the issue): credential_rotation(can_rotate, on_rotate)— a credential invalidated mid-flight, with the assumption that the holder eventually re-authenticates rather than trusting a cached token.record_reality_divergence(can_diverge, on_diverge)— a component's cached RECORD ceasing to match REALITY. Made the general abstraction, so "stale view after restart/cutover" AND "wrong/empty volume attached (record != reality)" are both usages of one factory rather than a domain-specific "volume" env.- Deliberately not shipped: message lost/reordered/duplicated (already
musil.channels+sim.NetworkModel— reuse those), and grace-window races (couple tobounded_clock; left downstream). Documented inenvironments.md.
Docs — API reference now covers every module (was stuck near the 0.6.2 surface)¶
docs/api.mdwas missing the modules added since 0.11.0, so the published site never surfaced them (issue #19). Added mkdocstrings sections forinduction,patterns,counterexample,diffing,profiling,cache, andtracing, and named the newinvariant_coverage/check_liveness_opensymbols in the metrics/open-systems headings. A test (test_docs_coverage) now fails if any public module lacks amusil.<module>section, so the docs cannot silently drift behind the code again.
Added — check_liveness_open: verify liveness under the same environments as check_open¶
check_liveness_open(system, *envs, goal=...)is the liveness counterpart ofcheck_open, so anEnvironmentSpec's faults are not written twice (once as env behaviors for the safetycheck_open, once as hand-rolled unfair actions forcheck_liveness). From issue #20. It merges everyenv.behaviorsinto the model and defaultsfairto the SYSTEM's own action names, so environment behaviors are UNFAIR — an adversarial environment is never assumed to eventually help reach the goal. Passfairexplicitly to mark an env behavior fairly scheduled (e.g. "the network eventually delivers");fair=()is strict.goal/goal_name/everywhere/leadsto_from/fair_strongpass through tocheck_liveness. Returns anOpenLivenessResultthat surfaces the environments' unverified assumptions. Env SAFETY guarantees stay withcheck_open(safety); this form verifies the liveness goal under the same shared faults.
[0.25.0] - 2026-08-29¶
Twelve features driven by the adoption issues (#5–#16) plus a coverage fix (#18), all stdlib-only so
pip install musil still installs zero third-party packages. Versions 0.13.0 through 0.24.0 are the
per-feature bumps the pre-commit hook made as these landed; none was published, so everything below ships
as 0.25.0.
Fixed — coverage() is no longer blind for actions_fn models¶
coverage(model)reported "all 0 actions fire and move" for a model whose action space comes fromModel.actions_fn, because it enumerated the empty staticmodel.actions(issue #18). It now derives the action universe from the namesactions_fnoffers across the reachable states (so a never-enabled kind is still counted).coverage(model, key=...)/coverage_of(graph, model, key=...)take aname -> kindbucketer: anactions_fnmodel whose per-state actions carry unique names (e.g.add_filter{field='status'}) reports usefully withkey=lambda name: name.split('{')[0]. Static-actionsmodels are unchanged when no key is given.
Added — musil.patterns: the Dwyer specification patterns as one-call helpers¶
always(p)(universality),absent(p)(absence),eventually(p)(existence<>p),recurs(p)(recurrence[]<>p), andresponds(trigger, response)(response[](trigger -> <>response)) name the common temporal properties and lower each onto the existingcheck/check_liveness, so a team picks the pattern instead of hand-encoding the temporal logic. From issue #16. Each returns its underlying result (Resultfor the safety patterns,LivenessResultfor the liveness ones); both areVerdicts, soassert_okworks uniformly.- Scope: this v1 is the GLOBAL-scope family. The scoped variants (after / between / until a delimiter) and the precedence family (which need the state to carry history) are deliberately deferred and noted on the issue.
Added — diff: the behavioral delta between two model versions¶
diff(model_a, model_b, *, canonicalize=...)explores both and reports states reachable in only one, transitions present in only one, and invariant outcomes that flipped on the shared states (aModelDiffwith.equivalent). From issue #14: makes a refactor reviewable as a behavioral delta ("same reachable states and validity") rather than a leap of faith. Reads only the two reachable graphs, so it is O(states + transitions); states must be hashable (it runsexplore).
Added — no_trace: spare musil's hot loop from coverage/tracing overhead¶
no_trace()is a context manager that pauses the current thread's trace function (coverage, or anysys.settraceline-tracer) for its block and restores it after — a no-op when nothing is tracing, so it costs nothing outside coverage/debug runs. From issue #13: a check that ran ~10s bare took ~120s under CI coverage because line-tracing the engine's hot loop is pure waste (nobody measures musil's own coverage). Wrap only the check; the docstring also documents the coarser[tool.coverage.run] omitroute. Dependency-free (justsys).
Added — as_regression_test / replay_actions: pin a counterexample as a committable test¶
as_regression_test(model, result)emits pytest SOURCE that replays the (minimized) counterexample and asserts the same invariant breaks — a fast, deterministic regression test that pins the exact interleaving without re-running the exhaustive search. From issue #15; feeds fromminimize(#12).replay_actions(model, names)is the primitive it emits against: replay a fixed action-name sequence from init, checking every invariant along the one path (no search), returning theResult. Raises if a named action is no longer available/enabled, so a stale regression test fails loudly rather than silently passing.
Added — minimize: delta-debug a counterexample to its minimal failing subsequence¶
minimize(model, result)drops actions from a violating trace and re-checks that replaying the reduced sequence from init still breaks the SAME invariant, returning a newResultwith the minimal action sequence (greedy 1-minimization). From issue #12.- Honest scope: a
check/check_allcounterexample is already BFS-shortest, so no strict subsequence of it can violate andminimizereturns it unchanged. Its value is on traces that are NOT step-minimal — a long simulation (VOPR) run, an externally assembled sequence, or any trace carrying actions irrelevant to the violation — and as the seed for a committable regression test (#15). Only invariant counterexamples are minimized (anokresult or a deadlock passes through); if the trace cannot be replayed deterministically it is returned unchanged rather than risking a wrong reduction.
Added — check_inductive: prove an invariant by k-induction over a finite domain¶
check_inductive(model, invariant, domain, *, k=1)proves an invariant INDUCTIVELY — base (Iholds withink-1steps of init) plus step (along every path whose firstkstates satisfyI, the next does) — and on failure returns a counterexample-to-induction: a state satisfyingIwith a successor that breaks it, so the author strengthensIinstead of widening the scope. From issue #6.- The "for all states s" is made concrete WITHOUT an SMT solver by quantifying over a caller-supplied
finite
domain(every assignment of the state's bounded variables) — the enumerable fragment, where exhaustion is a complete, sound decision procedure. ThedomainMUST include unreachable states: 1-induction over only the reachable set is trivially true for a passing model, so the unreachable states are exactly where a counterexample-to-induction lives.k > 1proves invariants that are true but not 1-inductive. The step only follows enabled transitions, so a guarded partialapplystays in bounds. - Honest boundary (see
INDUCTION_NOTES.md): with no finite domain to enumerate — an infinite value range, or an unbounded number of components — this is a bounded refutation, not a proof; closing that needs a symbolic engine (an optionalz3extra) kept out of the dependency-free core.
Added — check_cached: content-addressed verdict caching for CI¶
check_cached(model, version=...)returns a cached verdict when the model and options are unchanged, else runscheck, stores theResult, and returns it — so an unchanged model is not re-checked on every CI run (issue #10: a ~5-20s exhaustive check running on every pipeline even when nothing changed).clear_verdict_cache(cache_dir)empties it.versionis REQUIRED and is the caller's soundness obligation: actions, invariants andterminalare opaque callables, and a function'sreprcarries a per-run address, so the LOGIC identity has to be an explicit string the caller bumps when a body changes. The digest additionally folds in what CAN be read stably — action/invariant names, initial-state reprs, options, and which opaque hooks are present — so a structural edit invalidates the cache automatically; only a changed body under an unchanged name needs the bump. The cache is a directory of pickle files (holds the full trace with live states); load only a cache you produced, and writing is best-effort so a read-only dir just runs the check.
Added — canonical_by_fields: declarative symmetry reduction¶
canonical_by_fields(*fields, key=...)derives acanonicalizeby SORTING each named field (each of which holds a collection of interchangeable items) into a canonical order — the same thingcanonical_by_sortingbuilds, without hand-writing theget/putaccessors, for the common case where the interchangeable components already live in one field (a tuple of per-worker slices, a multiset of ids, a pool of domain values). Works on frozen dataclass and pydantic v2 states. From issue #11: a reducer with 5 interchangeable dimensions where hand-writing the relabelling was fiddly enough that the test scoped down to 2 instead.- Expressed as a
canonicalizeyou pass tocheck/explore(the same shape as the other two constructions), not a newModelfield, so the soundness obligation stays visible at the call site. Declarative does NOT mean automatically sound: it removes the boilerplate, not the obligation — an order-sensitive invariant or a field that names a component still makes the sort unsound, andsymmetry_reduction_soundstill has to pass on a small instance first.
Added — invariant_coverage: which invariants are load-bearing vs candidate-vacuous¶
invariant_coverage(model)/invariant_coverage_of(graph, model)are the dual ofcoverage(which is about actions). Over the reachable space each invariant isload_bearing— some GUARDED-OFF action, applied to a reachable state, WOULD violate it, so a guard is what keeps it true — orcandidate_vacuous— nothing ever threatens it, so it holds by construction and a green check says nothing about it.threatsmaps each load-bearing invariant to the actions that would break it. From issue #9: tellingunique-ids(holds by construction) fromkpi-never-resized(enforced only because a guard never offers resize).- The practical form of the vacuity idea
coveragedropped as too naive: reachability + near-misses over the graph, not reasoning about opaque invariant antecedents. It applies guarded-off actions to reachable states and asks whether the result violates each invariant; a partialapplythat relies on its guard (and raises without it) is skipped rather than crashing the analysis. Analyses the staticmodel.actionsand costs O(states x actions x invariants), so run it as an audit next tocheck, not inside it.
Added — profile: where a slow check spends its time¶
profile(model)runs the same reachable sweep ascheck, timed, and returns aProfile: time split by PHASE (invariants, enumeration, enablement,apply,canonicalize, visited-set), the hottest ACTIONS by cumulativeenabled+applytime and call count, states/second, and the dominant phase. From issue #8: a check ran ~120s under CI coverage and diagnosing it meant reverse-engineering that a guard rebuilt an expensive menu ~100k times — a profiler shows that in one run.- Separate from
check(not acheck(profile=True)flag that would change the return type), matchingcoverage/state_space_metrics. Profiles the FULL sweep, notcheck's early exit — a passing-but-slow check is what you profile. Acceptscanonicalizeandkeylikecheck. An instrumented sibling ofcheck's BFS: timing every call adds overhead, so the RELATIVE phase split is the signal, not the absolute seconds.
Added — Model.footprint: memoize an expensive actions_fn across states¶
Model.footprintis astate -> hashableprojection of the sub-stateactions_fnreads. When set,check/check_all/explorecache the enumerated action list onfootprint(state)and reuse it for every state that shares a footprint, so an expensive menu builder runs once per footprint instead of once per state. The framework-level version of thelru_cachesuch callers hand-write. Real case (from issue #7): a reducer whose per-tile menu depends on one tile but recurs across ~40 distinct tiles over ~2600 states — ~40 builds instead of ~7800.footprint_sound(model, states)is the gate. Unlikecanonicalize, a footprint does NOT quotient the space, so an unsound one does not shrink the sweep — it hands a state the WRONG memoized menu, so successors go wrong and reachable states (and the violations only they reach) can be missed. The validator groups the sampled states by footprint and returns False if any group'sactions_fnmenus differ (by action name). Passexplore(model).statesfor a hashable model, or a representative sample for a non-hashable one (footprint's target case, checked viakey=); like any sampling gate it can only disprove soundness on the states it sees.- Supersedes issue #5: the guard-memoization
ask there was first answered by
actions_fn(0.13.0);footprintcloses the residual "still recomputed once per state" gap.
Added — coverage: a green check does not say your behaviour was ever explored¶
coverage(model)/coverage_of(graph, model)report, over the reachable graph, the actions that are never enabled (dead — a guard bug that silently removes an interleaving the author believed was covered), always enabled (the guard never guards), and only ever self-looping (fires, changes nothing). Sets of names, not accusations: an always-enabledIdleor adversarialcrashis legitimate, and only the author knows which.- Reads the graph's recorded edges rather than re-calling
enabled, so it is O(edges) and stays correct under symmetry reduction — a reduced graph stores canonical successors, so re-applying the actions and comparing would misjudge which firings moved. A test pins that againstcanonical_by_sorting. - Deliberately not a field on
Result:checkearly-exits at the first violation, so the graph it saw is partial and coverage computed from it would be wrong in the direction that matters — actions look dead because the sweep stopped, not because they cannot fire. - Under
truncated,dead_actionsmeans only not yet seen enabled; the rendering saysCOVERAGE INCONCLUSIVErather than calling anything dead. musil.metricsalso reaches the API docs for the first time — it had no section inapi.md, sostate_space_metrics,metrics_ofandsolution_cardinalitywere undocumented on the site too.- From issue #2, item 1. The vacuity half of that item was dropped with the reason recorded: it proposed flagging invariants that hold across the whole reachable space, but in a passing model every invariant does — the signal would fire on every healthy model. Real vacuity detection needs the invariant's antecedent, and musil invariants are opaque callables.
Added — checking ergonomics for rich, real-world state machines¶
key=oncheck/check_all— astate -> hashableidentity used only for the visited-set + trace bookkeeping, so states that are NOT themselves hashable (a pydantic model, a dataclass withlistfields) can be checked directly, with no wrapper type. Distinct fromcanonicalize: no symmetry obligation, and the rich states are kept for the trace. Must be INJECTIVE over reachable states, though: a collision prunes a state and all its successors, silently dropping reachable states (there is no validator, unlikecanonicalize). (exploreand its tools still need hashable states.)Model.actions_fn— astate -> Iterable[Action]that supplies the actions available IN a state, for a STATE-DEPENDENT action space (apossibleActions(state)/offered-menu enumerator, or a bounded parameterized algebra whose enabled bindings depend on the state) instead of hand-guarding a large static product. Honoured bycheck,check_all, andexplore.check_all(distinct_by=...)accepts a callable(invariant_name, reason) -> hashableto bucket violations by a caller-defined CLASS — for when the reason embeds variable detail (ids, counts) that would otherwise explode into many spurious "distinct" entries.ActionSchema+enumerate_actions— declare a parameterized action (name, typed parameter slots with BOUNDED domains,apply/enabledtaking those params as kwargs);enumerate_actionsexpands the cartesian product into concreteActions (bound by value; the binding is encoded in each action's name). Makes the bounded-action-algebra pattern first-class.format_trace(render=...)andformat_trace_diff— customise per-state rendering; the diff variant shows only the FIELDS THAT CHANGED between consecutive states (dataclass / pydantic / Mapping), so a counterexample over rich states reads as deltas instead of full dumps.
Fixed¶
checknow canonicalizes SUCCESSOR states, not only the initial states (nxt = canon(action.apply (state))), matchingexplore. Previouslycanonicalize=was effectively a no-op incheck(symmetry reduction never kicked in on successors).- A single pydantic model is no longer mistaken for a collection of initial states.
_is_state_collectionnow treats any dataclass instance or pydantic model (which is iterable in v2) as ONE state, soModel(init=SomePydanticModel(...))is not shredded into(field, value)tuples.
[0.11.0] - 2026-08-12¶
Four of the five additions from
issue #1, raised by adopting musil in a
downstream workspace. All four are stdlib-only, so pip install musil still installs zero
third-party packages. The fifth — a Hypothesis strategy bridge — is the one that cannot be built
without a dependency and is left open as a decision rather than answered by writing the code.
(Versions 0.8.0 through 0.10.0 are the per-feature bumps the pre-commit hook made as these landed; none was published, so everything below ships as 0.11.0.)
Added — canonical_by_sorting / canonical_by_permutations: derive the symmetry reduction¶
check(model, canonicalize=...)has always accepted a symmetry reduction andsymmetry_reduction_soundhas always been able to grade one. The step between was missing: turning "these N components are interchangeable" into the function. Hand-writing it is where the mistakes live, and an unsound canonicalizer silently drops reachable states.canonical_by_sorting(get=..., put=...)sorts the per-component slices — O(N log N), the one that scales. For N workers over 3 values it turns3 ** Nstates into the multisets of size N: 243 → 21 at N=5. Tested at N ∈ {3, 4, 5} against the exact combinatorial count, withsymmetry_reduction_soundasserted in the same test, because a reduction that shrinks and lies is worthless.canonical_by_permutations(identities, relabel)takes the orbit minimum under every permutation — correct when something names a component (a leader index, an owner, a route id in a message), where sorting breaks the reference while leaving the state well-formed. Factorial in the number of identities, somax_permutations(default 5040 = 7!) makes that a loud error naming the sorting alternative rather than a hang.- Three tests make the distinction concrete on one model whose
leaderfield indexes the workers: sorting invents a violation the real model never has (and the gate rejects it), a relabel that moves the counts but forgets the leader fails the same way, and the full relabel is sound and reduces. - Neither construction removes the obligation to run
symmetry_reduction_soundon a small instance first. A model that grows aleaderfield next year makes a previously-sound sort unsound, and nothing about the sort itself changes to say so. - Prompted by issue #1, item 2. Partial-order reduction stays deferred (REDUCTION_NOTES.md); symmetry pays off sooner for these models.
Added — bounded_clock: timeouts in the explicit-state layer, finite by construction¶
simhas a virtual clock;Model/Actionhad none, so every timeout model (a wait with a deadline, a retry ladder that gives up) hand-rolled a tick action plus a bound. The bound is the easy thing to get wrong and getting it wrong is quiet: an unbounded clock does not fail, it hitsmax_statesand returnsResult(ok=True, truncated=True)— which reads as success toif result:and prints asOK — 1000000 states, no violations (TRUNCATED at cap).bounded_clock(get=..., put=..., horizon=..., name="clock")returns aBoundedClockwith the tickaction(enabled exactly whilenow < horizon, so the clock addshorizon + 1values — linear, and finite by construction),now, andat_horizon. Sameget/putaccessor style as the channel kit; the clock lives in a field of your own state.clock.binding(graph)answers the question a bound raises: was the horizon reached? False means the model finished on its own and a green result is about the whole model. True means the green result only says "no violation withinhorizonticks" — a weaker claim that should not be quoted as the stronger one. A test shows the case where no horizon is large enough, because the model can always tick: the answer is to change the model, not the number.- Deadlines stay ordinary guards over
now(s.now >= armed_at + delay); this supplies the time they read and the bound that keeps them finite, not the timeout semantics. terminal=clock.at_horizonis expected: at the bound the tick is disabled, so a state whose only move was the tick has none left andcheckcalls it a deadlock. Forgetting it therefore fails loudly with a trace ending at the horizon rather than answering something wrong quietly, and a test pins that behaviour.- Prompted by issue #1, item 3.
Added — areplay and acheck_refinement: hold an async implementation to the model¶
areplay(traces, step_fn=..., project=..., start=...)—replayfor a real system that has to be awaited.step_fnis awaited;projectmay be sync or a coroutine function (reading the real system usually awaits too). Same arguments, sameConformanceResult, same divergence indices.RefinementMonitor.aobserveandacheck_refinement(model, observations, abstraction)over anAsyncIterable— for observations that are produced by awaiting (polling a live service, an async generator) rather than collected into a list first. Streaming matters beyond convenience: a violation stops the polling instead of being found after an unbounded run has been drained.- The abstraction may now be async;
RefinementMonitor.observeraisesTypeErrorpointing ataobservewhen handed one, rather than abstracting every observation to a coroutine object and reporting a bogusunknown-stateviolation. - Why it was needed: the sync
replayforces oneasyncio.runper step. That cannot be called from a running loop, so noasync deftest can use it — and it gives every step its own event loop, so a connection pool or open transaction created in one step is dead in the next. A test asserts both halves: the workaround raisesRuntimeErrorinside a coroutine, andareplayruns a whole trace on the loop that opened the connection. replayandareplaydrive one shared loop (_drive, a sans-io generator): the trace bookkeeping, the step counters and the divergence report exist once and cannot drift apart. A test asserts the two agree ontrace_index,step_index,action,expected,actualandsteps_runfor the same buggy implementation.- Still no dependency —
asynciois stdlib. Prompted by issue #1, item 1.
Added — assert_ok: the counterexample survives the test runner¶
assert_ok(result, message=None)raisesAssertionError(str(result)), so a failing check inside a test reports the rendered trace (INVARIANT VIOLATED: … / Init: … / → action: …) instead of the result's repr.assert check(model)reported the repr, and pytest shortens a long repr by eliding its middle: the counterexample came out astrace=(Step(action='<init>', s...Step(action='inc', state=S(n=3)))— the initial state cut — followed by the wholeModelrepr, lambda addresses and all. The information was not merely ugly, it was incomplete.- Works on every musil result (
Result,LivenessResult,ConformanceResult,RefinementResult,OpenResult,SimReport) via the structuralVerdictprotocol, and sets__tracebackhide__so pytest does not reprint the elided repr as the helper's own frame. Still no dependency: it raises a plainAssertionError, sounittestand bareassertget the same message. - Prompted by feedback from adopting musil in a downstream workspace
(issue #1, item 5). The pytest plugin
from that report was deliberately not built: an entry point would make pytest a packaging concern
of a package whose headline property is
dependencies = [], and a plain function recovers the elided trace, which was the entire measured defect.
[0.7.0] - 2026-07-30¶
Prompted by feedback from verifying the discrete half of a Modelica simulation engine in
pymodelica — see IDEAS.md.
Added — reaches and fixed_points: reachability and confluence as first-class results¶
reaches(graph, predicate)— the states from which a matching state is still reachable (BFS over the reversed edges), with the complement being the trap. The weaker cousin of liveness: "can it still get there?" (AG EF) rather than "must it get there?" (AG AF).reaches(g, p) == set(g.states)says the goal stays available from every reachable state — e.g. "fmi3FreeInstanceis legal in every FMI mode". Users were hand-rolling exactly this reverse BFS;check_livenessnow uses it internally too.fixed_points(model)— for each initial state separately, the distinct terminal states (no outgoing edge) its runs can end in..confluentasserts no single start can settle in two different places, i.e. the fixed point does not depend on the order actions fired — validating, e.g., that event iteration is order-independent when nothing chooses the order on purpose. Per start deliberately: different starts settling differently is what a latch is for.- Both prompted by feedback from modelling the FMI 3.0 calling sequence and event iteration in pymodelica.
Fixed — everywhere=True documentation described a different (weaker) property¶
- README and the
check_livenessdocstring said "from any reachable state, P must always still be reachable" — which reads as reachability (AG EF). The implementation checks always eventually P (AG AF): a reachable cycle that never reaches P is a violation even when P stays reachable from inside it, unless fairness rules the loop out. The checker was right and the prose was wrong — a user modelling FMI wrote the wrong property from the old wording. The prose now states the checked property and points toreachesfor the weaker question.
Added — protocol_actions: transition tables whose edges are labelled by the call¶
transition_actionscovers{state: {states it may become}}. A published protocol has a different shape:{state: {operation: where it leaves you}}, where the edges are labelled by the call and most calls are legal without moving at all.protocol_actionsbuilds a model from that table directly, andprotocol_operations(table, state)lists what a runtime guard should allow.- The zero-drift argument, one level up: a protocol table is what a guard needs anyway — "is this call legal here, and where does it leave me" — so pointing the checker at it means the sequence being verified is the sequence being enforced, not a second description of it.
- Motivated by the FMI 3.0 calling sequence in pymodelica, where one table has to serve an exported FMU's guard, an importer's driver and the checker. Its first useful answer there was "this mode is unreachable" — a state transcribed from the specification into the wrong interface's table, which reading it will not show you.
Fixed — a str (or StrEnum) state was taken apart into its characters¶
Model(init="red", ...)was read as four initial states —"r","e","d"— because the "several initial states" heuristic accepted anything iterable that was not a dataclass. It did not raise; it checked a different model and reported OK, which is the worst way for a checker to be wrong.strandbytesare now always one state.- This bites hardest exactly where a plain-string state is most natural: a
StrEnumnaming the states of a published protocol (Instantiated,Event Mode, …), which is astrsubclass. Found while modelling the FMI 3.0 calling sequence in pymodelica, where every mode came back "unreachable" and the states were single letters.
[0.6.0] - 2026-06-28¶
Added — leader-election and vote-tallying examples¶
examples/leader_election.py— quorum-based leader election (the Raft/Paxos essence). Three results: no quorum → two leaders in one term (split brain); a majority → at most one leader per term (safe); and "a leader is eventually elected" is FALSE in general — the split-vote livelock, which is why real Raft needs randomized election timeouts. Docs page, subprocess test, nav entry.examples/voting_tally.py— vote-tally integrity: a racy read-then-write count drops a vote (lost update); the atomic count is correct (every eligible voter at most once, every ballot counted exactly once, tally == ballots). Explicitly delimits scope — ballot secrecy is a cryptographic property, not a state-machine one, so it is out of scope. Docs page, subprocess test, nav entry.
Added — symmetry reduction (opt-in, with a soundness gate)¶
check(..., canonicalize=f)andexplore(..., canonicalize=f)enable symmetry reduction: each state is mapped to a canonical representative of its symmetry-equivalence class, so interchangeable components (sort the per-worker slices, etc.) collapse and the state space stops blowing up combinatorially. Opt-in;canonicalize=Noneis the unchanged default.symmetry_reduction_sound(model, f)is the validation gate (the project's "ships only with a soundness check" rule for reductions): it explores fully and reduced and confirms both the canonical-quotient coverage and the set of violable invariants agree. Coverage alone passes for a lossy projection that merges non-equivalent states; the invariant-set check catches that — so the gate rejects an unsoundcanonicalize(or a non-class-constant invariant). Methodology: validate on small instances, then trustcanonicalizeon large ones. Does not cover deadlock-freedom. Reduced counterexample traces are witnesses up to symmetry. Tests intests/test_symmetry.py.
Added — invariant_from_violations adapter¶
musil.invariant_from_violations(checker, describe=…)adapts an external checker that returns a sequence of violation objects (empty = OK) into a musil invariant:Truewhen empty, else the first violation'sdescribe(...)string (a reason). The idiomatic, footgun-free way to drive a realworld -> [Violation]checker as an invariant — avoids thenot checker(s)trap where a truthy reason string reads as "holds". Documented that trap in theInvariantdocstring. Test intests/test_core.py.
Added — reachable_violations (exhaustive audit)¶
musil.reachable_violations(model)returns every(reachable state, broken invariant)pair asStateViolation(state, invariant, reason)— wherecheckstops at the first counterexample, this lists them all. For audits and simplification: enumerate the exact set of invalid states a model can reach (e.g. to derive which cross-resource combinations a guard must exclude). Same invariant semantics ascheck(string return = violation with reason). Test intests/test_core.py.
Added — leads-to (response) liveness¶
check_liveness(..., leadsto_from=Q)checks the response property[](Q -> <>P)— whenever Q holds, P eventually follows. This is the untimed "no resource gets stuck" property (e.g. every service that entersplacedeventually reachesrunning). Correctly weaker thaneverywhere=True: a ¬P cycle no Q-state can reach is not a violation. Implemented by reusing the existing<>Panalysis seeded from every reachable Q∧¬P state — no new cycle logic — with a soundness-distinguishing test (holds whereeverywherefails). Tests intests/test_liveness.py.
Added — FIFO (ordered) channel kit¶
musil.fifo_channel_actions/musil.fifo_send— the order-preserving counterpart tochannel_actions. In-flight messages are an ordered tuple; only the head is deliverable (and, iflossy, droppable), so messages arrive in send order with no reordering. For sequence-number protocols (alternating-bit, sliding-window) and any link whose correctness assumes order — where the unorderedchannel_actionswould wrongly explore reorderings (the ABP example had to hand-roll this). Caller bounds the queue from the sender side. Tests intests/test_channels.py.
Added — multi_status_field_actions for multi-resource models¶
musil.multi_status_field_actions(tables)— build the interleaved product of several status machines over one dataclass in a single call:tablesmaps each field name to itsALLOWED-style table, and actions are namespaced"<field>:<src>-><dst>". Removes the per-fieldtransition_actionsboilerplate every multi-resource model (e.g. a control-planeWorldwithnode/service/certfields) was hand-rolling. Test intests/test_fsm.py.
Added — invariants can carry a reason¶
Invariantmay returnstr. An invariant now returnsbool | str | None:Trueholds,False/Noneis a violation (as before), and a string is a violation that carries a human-readable reason, surfaced on the newResult.reasonfield and instr(result). Backward compatible (only non-empty strings change meaning, which no bool predicate returned). This makes wrapping a richer external checker first-class: e.g. driving a realcheck(world) -> [Violation]and returningf"{v.invariant}: {v.message}"so the counterexample names which sub-rule and entity broke, instead of a single generic invariant name. Tests intests/test_core.py.
Added — Alternating Bit Protocol example¶
examples/alternating_bit.py— ABP as fourcheck/check_livenesscalls. (1) On musil's unordered channel kit,checkproves ABP unsafe: the one-bit counter wraps and a two-generations-old frame is wrongly accepted — ABP assumes FIFO, which the kit does not model. (2) On a hand-rolled single-slot FIFO link with the bit stripped, the duplicate is delivered twice (3-step counterexample). (3) Real ABP on the FIFO link is safe. (4) Eventual delivery over the lossy link is false under weak fairness (drop-forever lasso) and true under strong fairness — the canonical demonstration of whyfair_strongexists. Docs page, subprocess test, and nav entry added. Surfaces an improvement formusil.channels: afifo=True(ordered) channel mode, so sequence-number protocols need not hand-roll their own.
Changed — TCAS II example revised to protocol-accurate coordination model¶
-
examples/tcas.py— four scenarios (was three). VRC constraint broadcast now modeled accurately:Aircraftgains avrc_receivedfield carrying the Mode S coordination constraint ("no-climb"/"no-descend"). The intruder's RA is forced by the constraint it received from own, not computed symmetrically. This matches the actual UF=16/DF=16 Mode S coordination protocol (BDS register 0x30, bits MB:23–26; RTCA DO-185B / ICAO Annex 10 Vol. IV). -
New scenario 4 — v7.1 reversal + Tenerife.
reversal_modeladds theown:ra=reversedsystem action (DO-185B v7.1 mechanism: own reverses when intruder has not maneuvered within the timing window; one reversal per encounter).tenerife_envinjectsintruder:maneuvers-with-old-ra(intruder executes its original RA while re-coordination is incomplete).check_openfinds the counterexample in 7 steps and surfacesINTRUDER_FOLLOWS_REVERSALas a residual obligation. Models the 2011 Tenerife near-collision (CIAIAC A-032/2011; Thomas Cook / Finnair). -
docs/paper.md— § 5.4 added: TCAS II as a fourth evaluation case study, including connection to Lynch's Hybrid I/O Automata model (Livadas, Lygeros & Lynch 2000) and the ACAS X formal verification line (Jeannin, Platzer et al. 2015/2017). References section updated with seven TCAS/ACAS formal verification papers.
[0.5.0] - 2026-06-24¶
Added — academic paper draft¶
-
docs/paper.md— paper draft targeting OOPSLA tool track / ISSTA / ECOOP tool track. Stakes the claim that musil is the first Python library to unify explicit-state model checking, deterministic simulation testing, and open-system contract verification. Formal grounding: Interface Automata (de Alfaro & Henzinger, ESEC/FSE 2001), contract-based design (Benveniste et al., INRIA RR-8147, 2012), I/O Automata (Lynch & Tuttle, 1987), seL4 assumption layering (Klein et al., SOSP 2009). Sections: Abstract, Introduction, Background and Related Work, Design, Implementation, Evaluation (three case studies), Limitations, Conclusion, References. -
mkdocs.yml— added paper to site nav as "Paper draft".
[0.4.0] - 2026-06-24¶
Added — pre-built adversarial environment library¶
-
musil.environments— new sub-package with five production-grade adversarial environments, each shipping namedAssumptionconstants (with real source citations), anEnvironmentSpecfactory forcheck_open, and anAdversarialNodefactory for simulation: -
k8s— pod eviction, OOM kill. Assumptions:k8s:node-capacity,k8s:scheduler-liveness,k8s:eviction-rate-bounded(all unverified; cites Kubernetes docs). Factories:pod_eviction,eviction_notifier,oom_notifier. -
aws— API throttling, request timeouts. Assumptions:aws:s3-eventual-delivery,aws:region-availability,aws:throttle-transient(unverified);aws:iam-correctness(verified; cites AWS IAM docs). Factories:aws_api_faults,throttling_service,timeout_service. -
linux— OOM kill, SIGTERM, ENOSPC filesystem errors. Assumptions:linux:oom-killer-bounded,linux:signal-delivery,linux:enospc-recoverable(unverified);linux:fs-rename-atomic(verified; cites rename(2) POSIX guarantee). Factories:process_faults,fs_faults,oom_notifier,signal_sender. -
sel4— capability revocation, IPC timeout. Assumptions:sel4:hardware-memory-safety(axiom; Klein et al., SOSP 2009 TCB),sel4:capability-integrity(verified; Klein et al., SOSP 2009 § 4 formal proof),sel4:ipc-liveness(unverified; not covered by the functional correctness proof). Factories:capability_revocation,cap_revocation_notifier. -
etcd— Raft leader change, write conflict, compaction. Assumptions:etcd:quorum-maintained,etcd:bounded-leader-election,etcd:compaction-bounded(unverified);etcd:unique-leader(verified; Ongaro & Ousterhout, USENIX ATC 2014 § 5 Raft safety). Factories:cluster_faults,etcd_adversary. -
examples/open_system_composed.py— end-to-end multi-environment example: a service composed against K8s + AWS + etcd simultaneously, showing a BFS-discovered counter-example (unbounded evictions exhaust retry budget) and the full residual proof obligation list. -
docs/environments.md— reference guide for the environments library: all assumptions, factory signatures, composition patterns, simulation usage, and custom assumption injection.
[0.3.0] - 2026-06-24¶
Added — open-system verification¶
-
EnvironmentSpec[S]— a named contract for an adversarial external component (K8s, AWS, seL4, Linux). Each spec carries:behaviors(the non-deterministicAction[S]objects the environment can take),guarantees(invariants the environment commits to), andassumptions(named proof obligations, following seL4's two-layer assurance model: Klein et al., SOSP 2009). -
Assumption— a named, machine-readable proof obligation withstatus ∈ {"axiom", "verified", "unverified"}, an optionalsourcecitation, and an optionalpredicatefor machine-checking. Axioms and unverified assumptions surface as residual obligations inOpenResult, making hidden hypotheses first-class. -
check_open(system, *envs)— verifies a system against one or more adversarial environments. Composes by merging environment behaviors into the system's action set (the BFS explores every adversarial move) and environment guarantees into the invariants (qualified as"<env.name>:<k>"). Returns anOpenResultwrapping the underlyingResultplus all unverified assumptions. With no envs,check_open(m) == check(m)exactly. Theoretical grounding: Interface Automata (de Alfaro & Henzinger, ESEC/FSE 2001) and contract-based design (Benveniste et al., INRIA RR-8147, 2012). -
OpenResult[S]— the result ofcheck_open:.ok/bool()delegates to the underlyingResult, and.unverified_assumptionssurfaces residual proof obligations. -
AdversarialNode— a simulationBaseNodethat injects Byzantine behavior via a list of(guard, response)pairs. The first matching guard fires; no match silently drops the message. Models external services that return wrong answers, violate protocols, or refuse to respond. Grounded in Byzantine fault injection (Castro & Liskov, OSDI 1999). -
NetworkModel.mutate— aCallable[[src, dst, payload], object | None]hook onNetworkModel. Applied after loss/duplicate decisions, before delivery.Nonereturn drops the message. Models wire-level Byzantine corruption: a component that sends wrong data, not just absent data. -
Worked examples —
examples/k8s_scheduler.py: a distributed service verified against K8s pod eviction (fragile service fails, resilient service with restart logic passes, residual proof obligations printed).examples/byzantine_service.py: anAdversarialNodethat returns wrong answers (client without validation fails, with validation passes) andNetworkModel(mutate=...)for wire-level payload corruption. -
docs/open-systems.md— a conceptual guide: closed vs. open systems, when to usecheck_openvs.check, and how to write anEnvironmentSpec. -
docs/api.md— new "Open systems" section documentingcheck_open,EnvironmentSpec, andAssumption.
Added — verify the implementation, not just the design¶
- Refinement (
check_refinement,RefinementMonitor) — check that an observed run of the real system refines a model: every transition is a model edge or a stutter (the runtime analog of seL4/TLA+ refinement). The oracle that holds real code to a model-checked spec. - Deterministic simulation (
simulate,Simulator,NetworkModel,BaseNode,Context) — run real, event-driven node code under a virtual clock and a fault-injecting network (loss / duplication / latency-driven reordering), reproducible from a seed.simulateruns many seeds with invariants + refinement + a convergence goal as the oracle and returns the first failing seed as a reproducibleSimFailure. This is the FoundationDB/TigerBeetle "deterministic simulation testing" technique as a pure-Python library -- bug finding, not proof. - Worked example (
examples/route_delivery.py) and a guide (Verifying a distributed system): model-check a protocol's design, then put the real code through simulation; the fire-and-forget bug is caught with a seed, the per-tick re-push converges and refines the model.
Added — the textbook (a canon of worked examples)¶
docs/textbook.md— a roadmap mapping the classic concurrency / distributed-systems problems (each bounded, ordering- or failure-driven — musil's diet) to the bug each teaches and the feature it exercises, so the example suite doubles as documentation and as musil's own coverage matrix.- Producer–consumer / bounded buffer (
examples/producer_consumer.py) — the check-then-act race: two non-atomic producers both pass the "there is room" check and overflow a one-slot buffer; the atomic test-and-insert stays within capacity, never deadlocks, and converges. - Shared-memory concurrency examples completing that textbook section: dining philosophers
(
examples/dining_philosophers.py— the circular-wait deadlock and the resource-ordering fix), readers–writers (examples/readers_writers.py— mutual exclusion holds, yet reader-preference starves the writer: a liveness/fairness lesson), and bank transfer (examples/bank_transfer.py— the lock-ordering deadlock plus a conservation invariant). examples/is now linted and type-checked (ruff + pyright strict) in CI andmake check, so the worked examples can't rot.
[0.1.0] - 2026-06-21¶
Initial release.
Added¶
- Core checker (
check) — breadth-first sweep of every reachable state for the first invariant violation or deadlock, with the shortest counterexample trace. States are immutable, hashable values (frozen dataclasses); actions are pure guarded transitions. - Concurrency by interleaving — modelling several actors is just handing
checkall their actions; every interleaving is explored. - Liveness (
check_liveness) — proves<>P("eventually P") and, witheverywhere=True,[]<>P("always eventually P" / convergence). Tarjan SCC + lasso counterexamples. Weak fairness viafair=[...]and strong fairness viafair_strong=[...]. - Composition (
compose) — the interleaved product of independent component models, lifting each component's invariants (name-qualified) and supporting joint invariants over the composite. - Channel kit (
channel_actions,send) — model a message channel as a building block: reliable / lossy / duplicating, unordered so reordering is explored for free. - Zero-drift FSM bridge (
transition_actions,status_field_actions,terminal_states,declared_states) — build a model straight from an allowed-transitions table so it can't diverge from the code. - Conformance (
generate_traces,replay) — generate traces from the model and replay them against the real implementation to check it refines the model. - Graph export (
to_dot) — Graphviz DOT of the reachable state graph, with counterexample highlighting; plusexplorefor the raw reachable graph. - CLI (
python -m musil <model.py>) — check a model module from the command line. - PEP 695 generics,
py.typed, zero runtime dependencies, Python 3.12+.
Known limitations¶
- Explicit-state: for bounded models (use the
max_statescap; results flagtruncated). - No partial-order reduction yet; heavy interleaving can be expensive.
- Liveness is fairness-based, not full LTL (
<>P/[]<>Punder weak/strong fairness).