Skip to content

Rule-based machines ​

The Command interface is the primitive: a model threaded as an immutable value, one class per command, a harness, a test. That is the right shape when the model is a separate value. For the common case — the model is a few fields, the commands are a few methods — the entry cost keeps stateful testing unused. Gen::rules() is a second façade over the same engine, in the shape Hypothesis's rule-based state machines use:

php
use Rasuvaeff\PropertyTesting\StateMachine\Invariant;
use Rasuvaeff\PropertyTesting\StateMachine\Precondition;
use Rasuvaeff\PropertyTesting\StateMachine\Rule;

final class QueueMachine
{
    private array $model = [];

    public function __construct(private readonly Queue $sut) {}

    #[Rule]
    public function enqueue(int $value): void          // parameters drawn like a property's
    {
        $this->sut->push($value);
        $this->model[] = $value;
    }

    #[Rule]
    #[Precondition('notEmpty')]
    public function dequeue(): void
    {
        Assert::same($this->sut->pop(), array_shift($this->model));
    }

    public function notEmpty(): bool { return $this->model !== []; }

    #[Invariant]
    public function sizeMatches(): void
    {
        Assert::same($this->sut->size(), count($this->model));
    }
}
php
#[Property(runs: 200)]
public function queueBehavesLikeAList(RuleSequence $sequence): void
{
    $sequence->run(static fn (): QueueMachine => new QueueMachine(new Queue()));
}

public static function queueBehavesLikeAListGenerators(): array
{
    return ['sequence' => Gen::rules(QueueMachine::class, maxLength: 50)];
}

What each attribute means ​

AttributeOnMeaning
#[Rule]a public instance methodOne step the sequence may take. Its parameters are drawn as Gen::forParameters() draws them: an override, the docblock type, the native type. Overrides come from a public static function <rule>Generators(): array on the machine, or from the method #[Rule(generators: 'name')] points at. The body drives the system under test, updates the model, and asserts — an exception is the failed postcondition
#[Precondition('method')]a ruleNames a public method of the machine returning bool. A step whose guard is false in the machine's current state is skipped, not failed — the same skip-on-replay contract a dropped Command step has
#[Invariant]a public instance method without parametersRuns before the first step and after every executed one; an exception falsifies the sequence at that step

Gen::rules() reads the class once and refuses, by name, a machine with no rule, a rule or invariant that is not a public instance method, an invariant with parameters, a guard that does not exist or is not public, and a generators method that is missing, not public static, not an array, or not keyed by parameter name.

How it runs and shrinks ​

Each #[Rule] method becomes a RuleStep — a Command under the hood whose model is the machine object itself, so the sequence is generated and shrunk exactly as a Gen::commands() sequence is: steps dropped in blocks, each step's arguments simplified through its tree. At generation time there is no machine yet and every step is applicable; at run time the guard decides.

RuleSequence::run($factory) builds a fresh machine from the factory every time, so a shrink trial never sees state a previous trial left behind, checks the invariants once, then walks the steps. The factory is handed to run() rather than kept in the value — the split StateMachine::check() makes — so the sequence stays a plain value: it renders as the trace, serializes inside a result, and never carries a closure into the corpus.

Property falsified after 3 successful run(s); seed=42
  Original: sequence=[push(value: 7), push(value: 2), pop(), push(value: 9), pop()]
  Shrunk:   sequence=[push(value: 0), push(value: 1), pop()] (11 shrink step(s))
  Failure:  popped 0, expected 1

Hypothesis's bundles — values produced by one rule and drawn by another, an id returned by create consumed by delete — are not in this first cut; a rule reads the model for them. Command stays the documented pattern for a machine whose model is a separate value.