Tests cannot pin the dice a subsystem rolls for itself (the cause behind #119) #141

Closed
opened 2026-08-06 15:14:53 +00:00 by sickday · 1 comment
Owner

Contract.Skilling.dice/0 is the production source of randomness and there is no way for a test to supply its own. That makes a whole class of test either flaky or impossible, and it has now bitten twice in different subsystems — #119 is one of them, and this is the shared cause rather than a note on that one test.

World.RunecraftWorldTest mined the essence rock and waited 12s for the yield. That deadline is not seconds, it is rolls: 12,000ms ÷ 600ms is 20 ticks, and a pickaxe legal at Mining 1 is bronze (period 8) or iron (7), so the wait bought two rolls against a low: 129-out-of-256 rock. One required success, 24.6% failure — measured, it failed once in three runs of the file alone. It surfaced as a Postgres-leg failure while SQLite was green, which reads as adapter-dependent and is only the two legs landing on different sides of the same flip.

That one was fixed by moving the branch it was actually asserting (Mine.product/2, pure and public) into deterministic unit tests, and leaving the world test the single claim only the world can make. Sizing the remaining wait at eight rolls puts a miss under 1%.

What could not be done in either case is pin the roll. Contract.Skilling.dice/0 is &(:rand.uniform(&1) - 1), and :rand reads its state from the process dictionary of whatever process calls it — here the world's or the player's, never the test's. So a test cannot seed the randomness it depends on, which is exactly why this issue's failure attaches to whichever file was added to the selection last: the only lever a test has is the global run order.

The fix that closes both shapes is a seam — a way for a test to supply the dice() a subsystem uses, rather than each test either rolling the dice again itself or buying more attempts. Contract.Skilling.dice/0 is the single place it would go, and most callers already take a dice argument and only fall back to it; Light.run/2 and World.Content.Mine are the ones that call it for themselves.

Until that exists, the workaround for any new test is mechanical: count the rolls the deadline buys (patience ÷ 600ms ÷ period), and if the answer is small, assert the pure function instead.

`Contract.Skilling.dice/0` is the production source of randomness and there is no way for a test to supply its own. That makes a whole class of test either flaky or impossible, and it has now bitten twice in different subsystems — #119 is one of them, and this is the shared cause rather than a note on that one test. `World.RunecraftWorldTest` mined the essence rock and waited 12s for the yield. That deadline is not seconds, it is **rolls**: 12,000ms ÷ 600ms is 20 ticks, and a pickaxe legal at Mining 1 is bronze (period 8) or iron (7), so the wait bought **two rolls** against a `low: 129`-out-of-256 rock. One required success, 24.6% failure — measured, it failed once in three runs of the file alone. It surfaced as a Postgres-leg failure while SQLite was green, which reads as adapter-dependent and is only the two legs landing on different sides of the same flip. That one was fixed by moving the branch it was actually asserting (`Mine.product/2`, pure and public) into deterministic unit tests, and leaving the world test the single claim only the world can make. Sizing the remaining wait at eight rolls puts a miss under 1%. **What could not be done in either case is pin the roll.** `Contract.Skilling.dice/0` is `&(:rand.uniform(&1) - 1)`, and `:rand` reads its state from the **process dictionary of whatever process calls it** — here the world's or the player's, never the test's. So a test cannot seed the randomness it depends on, which is exactly why this issue's failure attaches to whichever file was added to the selection last: the only lever a test has is the global run order. The fix that closes both shapes is a seam — a way for a test to supply the `dice()` a subsystem uses, rather than each test either rolling the dice again itself or buying more attempts. `Contract.Skilling.dice/0` is the single place it would go, and most callers already take a `dice` argument and only fall back to it; `Light.run/2` and `World.Content.Mine` are the ones that call it for themselves. Until that exists, the workaround for any *new* test is mechanical: count the rolls the deadline buys (`patience ÷ 600ms ÷ period`), and if the answer is small, assert the pure function instead.
Author
Owner

Fixed on main: Contract.Skilling.pin/1 lets a test pin the dice a subsystem rolls for itself, and dice/0 reads the pin from the process dictionary. Not carried over to forge.home.arpa.

Fixed on main: `Contract.Skilling.pin/1` lets a test pin the dice a subsystem rolls for itself, and `dice/0` reads the pin from the process dictionary. Not carried over to forge.home.arpa.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Revenant/Server#141
No description provided.