Skip to content

test: model the state machine as a hypothesis RuleBasedStateMachine - #112

Merged
bagowix merged 3 commits into
mainfrom
feat/106-state-machine-rulebased
Aug 1, 2026
Merged

test: model the state machine as a hypothesis RuleBasedStateMachine#112
bagowix merged 3 commits into
mainfrom
feat/106-state-machine-rulebased

Conversation

@bagowix

@bagowix bagowix commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

tests/test_state_machine_properties.py generates a config and then drives a
fixed, hand-written sequence — good at the boundaries it is pointed at, blind
to order-dependent corruption. This adds tests/test_state_machine_model.py: a
hypothesis RuleBasedStateMachine that generates the sequence as well, so an
override during a probe round or a probe settling an era late has somewhere to
show up.

  • Rules: advance (fractions of the open wait, so 1.0 lands exactly on
    the boundary), admit, settle, release, auto_transition, force_open,
    disable, metrics_only, reset.
  • Bundle: an admitted call yields a ticket — the generation the call layer
    captures — and settle / release consume one, so probes settle out of
    order, or an era late.
  • Invariants, after every step, against the model's own prediction rather
    than values read back from the machine: state, generation (the stale-outcome
    fence), the Make manual controls authoritative for coordinated breakers #79 contract that no automatic transition undoes an operator
    override, the probe budget cross-checked against the machine's own
    accounting, window aggregates vs. the recorded history, and retry_after()
    as the remaining wait in OPEN.
  • I/O-free: all time comes from FakeClock.

The Config strategy moves to conftest.py as configs(), now shared by both
hypothesis suites. The model narrows minimum_number_of_calls on top of it —
every recorded outcome costs two steps, so an unnarrowed run spends all 50
steps inside CLOSED and never reaches a probe round. The existing four
properties stay unchanged (bar the import).

No counterexample against the current implementation, so there is no regression
test to pin yet. Verified the model actually bites, by mutation:

Mutation Result
_admit_probe: >=> on the concurrency cap fails (admit predicted a rejection)
record(): drop the generation fence fails (window_matches_history)
release_probe(): drop the generation/state guard fails (probe_budget_holds, -1 == 0)

Also here, since it is the same free-threading thread: pyproject.toml now
declares Programming Language :: Python :: Free Threading :: 3 - Stable.
There is no 3.14t version classifier — free-threading is a separate trove
axis. CI has run the whole suite on 3.14t since #111 and
tests/test_concurrency.py backs the claim, but the metadata said nothing
about it. The #111 changelog entry asserted the opposite ("no wheels, no
classifiers"), so it is corrected in the same commit: the wheel half still
holds (pure Python — one wheel serves both flavours), the classifier half no
longer does. Verified with twine check on the built sdist and wheel.

Checklist

  • Tests added or updated (suite stays at 100% coverage) — 548 passed,
    coverage 100%; the model file runs in ~1.0s (max_examples=200,
    stateful_step_count=50)
  • uv run ruff format --check and uv run ruff check pass
  • uv run mypy and uv run pyright pass
  • Docs updated (docs/) for user-facing changes — n/a: no public API
    surface changed (the test-suite side is documented in tests/CLAUDE.md)
  • CHANGELOG.md [Unreleased] updated
  • Commits follow Conventional Commits

Related issues

Closes #106.

The four properties in test_state_machine_properties.py generate a config
and then drive a fixed, hand-written sequence. That finds arithmetic errors
at the boundaries it is pointed at, but not order-dependent corruption: an
override during a probe round, a probe settling an era late, a manual reset
while the open wait elapses.

Add a RuleBasedStateMachine that generates the sequence too. Rules: advance
the FakeClock, admit, settle, release, auto-transition, force_open, disable,
metrics_only, reset. An admitted call yields a ticket (the generation the
call layer captures) into a bundle, and settling consumes one — so probes can
settle out of order or long after their era ended.

The model carries its own prediction of the state, the generation, the window
aggregates and the probe budget, derived from the documented contract, and
@invariants compare it against the machine after every step — including the
#79 contract that no automatic transition undoes an operator override, and
the retry_after estimate in OPEN.

The shared Config strategy moves to conftest as configs(); the model narrows
minimum_number_of_calls so a bounded run reaches the probe round instead of
spending every step in CLOSED. Bounded by max_examples / stateful_step_count:
the file runs in ~1s.

No counterexample against the current implementation. Verified it bites by
mutation: loosening the concurrency cap, dropping the generation fence in
record(), and dropping the guard in release_probe() each fail the model.

Closes #106
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Coverage report

This PR does not seem to contain any modification to coverable code.

@codspeed-hq

codspeed-hq Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 21 untouched benchmarks


Comparing feat/106-state-machine-rulebased (74b0538) with main (faf35f6)

Open in CodSpeed

bagowix added 2 commits August 1, 2026 20:56
CI has run the whole suite on free-threaded 3.14t since #111, and
tests/test_concurrency.py is what backs the thread-safety claim, but the
package metadata said nothing about it. There is no `3.14t` classifier —
free-threading is a separate trove axis — so declare `Free Threading ::
3 - Stable` alongside the version classifiers. Verified with `twine check`.
The 3.14t entry said free-threaded builds are "not a distribution target
(no wheels, no classifiers)". The wheel half still holds — the package is
pure Python, so one wheel serves both flavours — but the classifier is now
declared, so the entry can no longer claim otherwise.
@bagowix
bagowix merged commit e06560f into main Aug 1, 2026
13 of 14 checks passed
@bagowix
bagowix deleted the feat/106-state-machine-rulebased branch August 1, 2026 18:02
bagowix added a commit that referenced this pull request Aug 1, 2026
## Summary

The `RuleBasedStateMachine` from #106 (#112) shrank a counterexample on
`main`.
It reproduces locally but not in CI — hypothesis found it on a seed CI
has not
drawn yet, so today it is a latent flake rather than a red build:

```
config: minimum_number_of_calls=1, failure_rate_threshold=1.0,
        permitted_calls_in_half_open=1, max_concurrent_probes=1
admit → settle(FAILURE)        # CLOSED → OPEN
advance(1.0)                   # the open wait elapses
admit → settle(FAILURE)        # HALF_OPEN probe fails → OPEN
```

At the last step the machine still holds `_probes_admitted == 1` while
the model
predicted `0`: the model forgets the probe round on *every* transition,
and the
machine clears it on entry to `HALF_OPEN` (`_to_half_open`) and `CLOSED`
(`_close`), not on `_open()`.

**The machine is right.** The counters are read only by `_admits_probe`,
which
is reachable only in `HALF_OPEN`, and `HALF_OPEN` is only ever entered
through
`_to_half_open()` — which resets them. Adding a reset to `_open()` would
be
defensive code written for a test, not for a caller. What the model
asserted
there was an internal detail the contract does not promise.

So `probe_budget_holds` now compares the machine's counters against the
model
only in `HALF_OPEN`, where the budget is spent and the contract defines
it; the
cap assertions (`max_concurrent_probes`, `permitted_calls_in_half_open`)
stay
unconditional. No production code changes.

## Test plan

- [x] `uv run pytest tests/test_state_machine_model.py` passes, plus 6
explicit
      `--hypothesis-seed` runs
- [x] **Mutation check — the invariant still bites.** With the
      `self._probes_in_flight -= 1` line deleted from
`StateMachine._record_probe` (a leaked probe slot), the scoped invariant
fails within one run at the same assertion. Scoping removed the false
      alarm, not the sensitivity.
- [x] The shrunk sequence is pinned as
`test__probe_budget__failed_probe_reopens__next_round_starts_full`, as
`tests/CLAUDE.md` requires — it asserts what *is* promised: the next
probe
      round starts from a full budget.
- [x] `uv run pytest --cov` — 549 passed, 100.00%
- [x] `uv run prek run --all-files` clean

## Related issues

Follow-up to #106 / #112. Independent of #113.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Model the state machine as a hypothesis RuleBasedStateMachine

1 participant