ADR-003: Explicit caller-owned random source
Date: 2026-07-19
Status: Accepted
Context
The architecture for v0.1.0 section 8.2 required that randomness, if it is introduced, enters explicitly, supports a fixed seed in tests, avoids a process global generator, and keeps the selected behaviour reproducible. It also recorded that the chosen autonomous transition is a threshold on elapsed time and needs no randomness at all.
PBI-019 requires a minimal random value input contract with valid ranges, consumption rules,
support for deterministic test doubles, defined behaviour for invalid input, and no allocation. The
platform random implementation belongs to a host.
Two questions had to be answered. The first was how far the contract should reach into the Core while no behaviour consumes a random value yet. The second was how to map a raw generator value onto a bounded range.
Section 11.1 lists adding random behaviour before deterministic transitions are stable as a risk, so
the answer had to avoid disturbing the transition delivered by PBI-018.
Decision
PetRandom is a caller-declared structure holding one uint64_t of state. pet_random_seed selects
a sequence and pet_random_next draws the next value below an exclusive bound.
The source is standalone. It is not a field of PetWorld, it is not a parameter of
pet_world_update, and no current behaviour consumes a value from it. The idle to curious transition
remains a pure function of total elapsed time.
The generator is SplitMix64. The state advances by a fixed odd step and the mixing turns that counter
into well distributed output. Every operation is on uint64_t, where overflow is defined, so the
wraparound the algorithm relies on is not undefined behaviour.
The bounded draw multiplies the high 32 bits of the mixed value by the bound and takes the high half of that product. There is no modulo and no retry loop.
Accepted behaviour:
- every seed is accepted, including zero
- a bound of zero is rejected with
PET_ERR_INVALID_ARGUMENT - a bound of 1 succeeds, yields 0, and still advances the source
- a rejected draw consumes nothing and leaves the source and the output untouched
- equal seeds produce equal sequences, and reseeding restarts one
- assigning a source copies it, and the copy continues the same sequence independently
Consequences
A behaviour that needs variation can take a PetRandom * argument when it arrives. Until then the
contract exists without a consumer, which is what PBI-019 asks for and what keeps PBI-018
untouched. The alternative, wiring randomness into the one transition that exists, would have revised
a shipped behaviour to give a new contract something to do.
Reproducibility is total. A seed and a call sequence determine every value, so a failing scenario can be replayed exactly and a test never needs a tolerance.
The draw costs the same for every bound. It contains no loop, so no caller can be delayed by an unlucky value and the work is derivable from the declared contract rather than from a probability.
The price is that the mapping onto a bound that is not a power of two is slightly uneven. Some values receive one more of the 2^32 possible inputs than others, so the relative deviation from a uniform distribution is bounded by the bound divided by 2^32. For a bound of 6 that is about 1.4e-9, and a power of two divides exactly and has no deviation at all. Detecting the unevenness at the range sizes a pet behaviour selects among would take on the order of 10^16 draws. It becomes material only for a bound in the tens of millions, which this contract is not intended to serve. If such a range is ever needed, this decision is superseded rather than patched.
PetRandom is 8 bytes and belongs to the caller. It does not enlarge PetWorld, so the storage
bound asserted by the type tests is unaffected.
Pet Core still calls no allocator and no platform entropy source. The undefined symbol scan of
libpet_core.a remains empty.
Because the algorithm is a decision rather than an implementation detail, the sequence is pinned by
golden vector tests. Their expected values come from an independent implementation of the published
algorithm, so they confirm that the constants are the real SplitMix64 rather than only recording what
this code happens to produce. The raw outputs for seed 0 match the published vectors, beginning
0xE220A8397B1DCDAF.
This makes the sequence a compatibility promise for the release. Changing the generator or the reduction becomes a deliberate act that updates this record and those vectors, in the same way that the world storage bound makes growth deliberate. The property tests alone do not provide that: inverting a single bit of a mixing constant leaves all of them passing, and only the vectors fail.
Alternatives considered
A random value supplied to pet_world_update. Makes the host boundary explicit immediately, but
changes a shipped signature for a parameter that nothing reads, and forces a consumption rule to be
invented before there is a behaviour to consume anything.
Randomness inside the idle to curious transition. Would give the contract a genuine consumer, but
revises the behaviour PBI-018 delivered and breaks its documented promise that the same total
elapsed time always produces the same activity. Section 11.1 names this risk directly.
A generator owned by the world, seeded through PetWorldConfig. Keeps a per-world sequence
without a separate caller-owned value, but grows PetWorld, adds a field that no behaviour reads,
and ties the lifetime of a generator to a world before anything needs that relation.
Modulo reduction. Simpler to read, but produces unevenness of the same order as multiply and shift, so it trades nothing for that simplicity, and it consumes the low bits of the value rather than the strongest ones.
Rejection sampling. Exactly uniform for every bound. Rejected because its worst case iteration
count is unbounded. The expected number of retries is below two, but PBI-020 requires that maximum
loop counts are derivable from declared capacities, and a probabilistic bound does not satisfy that.
A stronger generator such as PCG or xoshiro. Better statistical quality, but a wider state and more code for a use that selects among a handful of calm behaviours. SplitMix64 passes the standard test suites for this purpose and fits in one 64-bit word.