mars::rng: the seven draw entry points, with their word costs

A per-turn RNG budget is only as good as the entry-point table, and ours had
three of the seven. Adds the two that are modellable and documents the rest.

  float_range(lo, hi)     exactly one word.  Narrows TWICE -- the scaled product
                          is stored to a 4-byte float before lo is added, and the
                          sum is stored again.  Evaluating in double and narrowing
                          once disagrees on a measurable fraction of words, and the
                          test asserts the two models are distinguishable so the
                          shortcut cannot creep back.
  int_range_bell(lo, hi)  AT LEAST TWO words.  Triangular, not uniform: the span is
                          split into h/2 and h - h/2 (truncating toward zero) and
                          each half drawn inclusively, first half first.  The bounds
                          reach the draw as unsigned, so an inverted range yields a
                          huge first bound rather than an empty one; reproduced, not
                          corrected.

Documented but deliberately not modelled: a truncated-normal integer range built
on rejection sampling around a Box-Muller pair.  It costs TWO WORDS PER ATTEMPT
and the attempt count is unbounded, and predicting its stream position needs log,
sqrt and cos to agree bit for bit with the original CRT.  Nothing in the strategic
turn reaches it.  It is recorded so a ledger that meets it does not score its two
words as one draw.

Also recorded in docs/mars-rng.md, because each is a way a word budget goes wrong:

  * two calling conventions for one generator -- three entry points take the state
    block (the object plus four bytes) and four take the object itself, and one
    caller uses both within forty bytes of itself;
  * two different divisors in the same image, 1/(2^32 - 1) for the unit draw and
    2^-32 (with a +0.5 offset on the word) for the normal path;
  * the unit draw is inlined at twenty-eight sites across eleven functions, so any
    budget assembled by counting calls is a LOWER BOUND.  Exactly one of those
    eleven is reachable from the strategic turn driver.

Host ctest 36/36 and tools/clean_room_check.sh run as separate commands, both
clean.  No src/shim change, so no cross-build is implicated.
This commit is contained in:
alex 2026-09-08 09:59:05 -04:00
parent db0065f8c5
commit dc43f93910
4 changed files with 148 additions and 0 deletions

View file

@ -78,6 +78,63 @@ corrections below are behaviour changes, not cosmetics.
half-open range the notes assumed. `n == 0` masks to 0 and still consumes a word. The bound
reaches the callee **by pointer**, which is why the prototype had stayed unverified.
## The entry points, and what each one costs in words
A per-turn RNG budget is only as good as this table. Counting "draws" is not enough: the
entry points differ in how many words one call spends, and two of them spend an unbounded
number. Seven distinct entry points are recovered; three of them were in no earlier list.
| entry point | receives the generator as | words per call |
|---|---|---|
| unit / float draw | `&mt` (object **+ 4**) | exactly 1 |
| bounded integer, inclusive | `&mt`, bound **by pointer** | 1 + rejections |
| probability test | the object | **0 or 1** — short-circuits with no draw when `p <= 0` or `p >= 1` |
| raw 32-bit draw | the object | exactly 1, unconditional |
| float range (`float_range`) | the object | exactly 1 |
| triangular integer range (`int_range_bell`) | the object, as a **stack** argument | **≥ 2** |
| truncated-normal integer range | the object, as a stack argument | **2 per attempt, unbounded** |
Three consequences worth stating separately, because each one is a way a ledger goes wrong:
1. **Two conventions for the same object.** The unit and bounded-integer draws are entered
with a pointer to the state block, i.e. the generator **plus four bytes**; the other five
are entered with the generator itself and do the `+ 4` internally. One caller uses both
conventions within forty bytes of itself. Reading the wrong base is the failure mode
`guides/method-rules.md` rule 1 already paid for once, at a different offset.
2. **Two different divisors live in the same image.** The unit draw scales by `1/(2^32 - 1)`;
the truncated-normal path scales by `2^-32` and offsets the word by `+0.5` first. They are
not interchangeable, and the second is not modelled here.
3. **`float_range` narrows twice.** `lo + (hi - lo) * unit` is computed with the *product*
stored to a 4-byte float before `lo` is added, and the sum stored to a float again.
Evaluating the whole expression in double and narrowing once disagrees on a measurable
fraction of words; `test_rng.cpp` asserts the two models are distinguishable so the
shortcut cannot creep back in unnoticed.
`int_range_bell` is triangular, not uniform: the span is split into `h/2` and `h - h/2`
(truncating toward zero) and each half is drawn separately, so the sum peaks in the middle.
The two bounds reach the draw as unsigned, so an inverted range does not produce an empty
result — it produces a very large first bound. That is reproduced rather than corrected.
The truncated-normal generator is **documented but not modelled**: it is rejection sampling
around a Box–Muller pair, so it needs `log`, `sqrt` and `cos` to agree bit for bit with the
original's CRT before its stream position can be predicted, and nothing in the strategic turn
reaches it. Its cost is recorded here so that a ledger which one day sees it does not mistake
its two-words-per-attempt for one draw.
## Draws the call graph cannot see
The unit draw is **inlined** at several sites, so the only edge such a site leaves in a call
graph is one to the twist — which reads as a bare twist and is not one; it is the lazy twist
*inside* the draw. Any word budget assembled by counting calls to the entry points above is
therefore a **lower bound**, and the way to find the rest is to scan for the tempering
immediates at real instruction boundaries rather than for calls. Image-wide, eleven game
functions carry such a site — twenty-eight sites between them — besides the ones inside the
entry points themselves. **Exactly one of the eleven is reachable from the strategic turn
driver**, and one more from the combat path; the other nine belong to map setup, the lobby,
the network layer and two scripted encounters.
## Choices that still need binary confirmation
1. **Twist timing at the block boundary.** We twist lazily when `left` reaches 0 (so a saved

View file

@ -65,6 +65,23 @@ uint32_t MT19937::cover_mask(uint32_t n) {
return mask;
}
float MT19937::range_from(uint32_t y, float lo, float hi) {
// The original's order, instruction for instruction: form (hi - lo) and the unit
// value on the x87 stack, multiply, STORE THE PRODUCT TO A FLOAT, reload, add lo,
// STORE AGAIN. Two narrowings, not one.
const double span = static_cast<double>(hi) - static_cast<double>(lo);
const float scaled = static_cast<float>(span * unit_from(y));
return static_cast<float>(static_cast<double>(lo) + static_cast<double>(scaled));
}
int32_t MT19937::int_range_bell(int32_t lo, int32_t hi) {
const int32_t h = hi - lo;
const int32_t half = h / 2; // C++ truncates toward zero, as the original does
const uint32_t a = next_int_inclusive(static_cast<uint32_t>(half));
const uint32_t b = next_int_inclusive(static_cast<uint32_t>(h - half));
return static_cast<int32_t>(static_cast<uint32_t>(lo) + a + b);
}
uint32_t MT19937::next_int_inclusive(uint32_t n) {
const uint32_t mask = cover_mask(n);
for (;;) {

View file

@ -59,6 +59,27 @@ public:
// to 0 and is accepted on the first draw).
uint32_t next_int_inclusive(uint32_t n);
// --- the two range helpers the original also exposes ----------------------
// These are separate entry points in the original, not caller-side arithmetic,
// and their *word cost* differs. Both are documented in docs/mars-rng.md.
// lo + (hi - lo) * unit, EXACTLY ONE WORD. The two narrowings are part of the
// contract: the original stores the scaled product to a 4-byte float before
// adding lo, and stores the sum to a float again. Doing the whole expression in
// double and narrowing once gives a different last bit on some words.
float float_range(float lo, float hi) { return range_from(next_u32(), lo, hi); }
static float range_from(uint32_t y, float lo, float hi);
// Triangular integer on [lo, hi]: the span is split in two and each half is drawn
// uniformly, so the sum is peaked at the middle. AT LEAST TWO WORDS -- each of
// the two inclusive draws carries its own rejection loop.
// h = hi - lo; half = h / 2 (truncated TOWARD ZERO, so a negative span rounds
// up, matching the original's cdq/sub/sar idiom)
// return lo + next_int_inclusive(half) + next_int_inclusive(h - half)
// The two bounds reach the draw as uint32, so an inverted range (hi < lo) makes
// the first bound huge rather than empty; that is reproduced, not corrected.
int32_t int_range_bell(int32_t lo, int32_t hi);
// --- pure mappings (no draw), so tests can pin them word by word ----------
static double unit_from(uint32_t y) { return static_cast<double>(y) * kUnitScale; }
static float float_from(uint32_t y) { return static_cast<float>(unit_from(y)); }

View file

@ -195,6 +195,59 @@ int main() {
for (int i = 0; i < MT19937::N - 5; ++i) r.next_u32();
for (int i = 0; i < 100; ++i) CHECK(s.next_u32() == r.next_u32());
}
// --- range helpers: word cost is the point, not just the value --------------
// The original exposes these as separate entry points and they cost different
// numbers of words. A ledger that counts "draws" without knowing which entry
// point was called is short by exactly the difference.
{
// float_range: EXACTLY ONE word, and it is the same word next_float() would
// have taken -- so a caller that swaps one for the other keeps the stream.
MT19937 a(5489u), b(5489u);
const uint32_t y0 = b.next_u32();
const float got = a.float_range(-1.0f, 3.0f);
CHECK(a.left() == MT19937::N - 1); // one word, not two
CHECK(got == MT19937::range_from(y0, -1.0f, 3.0f));
// degenerate span consumes a word all the same
MT19937 c(7u);
CHECK(c.float_range(2.5f, 2.5f) == 2.5f);
CHECK(c.left() == MT19937::N - 1);
// endpoints: unit is closed at both ends, so both endpoints are reachable
CHECK(MT19937::range_from(0u, -1.0f, 3.0f) == -1.0f);
CHECK(MT19937::range_from(0xffffffffu, -1.0f, 3.0f) == 3.0f);
// the double narrowing is observable: rounding the product to float first is
// not the same as evaluating the whole expression in double.
int differs = 0;
MT19937 d(11u);
for (int i = 0; i < 20000; ++i) {
const uint32_t y = d.next_u32();
const double span = 3.0 - (-1.0);
const float one_rounding = static_cast<float>(-1.0 + span * MT19937::unit_from(y));
if (MT19937::range_from(y, -1.0f, 3.0f) != one_rounding) ++differs;
}
CHECK(differs > 0); // if this ever reads 0 the two models are not distinguishable
}
{
// int_range_bell: AT LEAST TWO words, and the split is (h/2, h - h/2) in that
// order. h = 7 -> half = 3, so the draws are inclusive [0,3] then [0,4].
MT19937 a(5489u), b(5489u);
const int32_t got = a.int_range_bell(10, 17);
const uint32_t d0 = b.next_int_inclusive(3u);
const uint32_t d1 = b.next_int_inclusive(4u);
CHECK(got == int32_t(10u + d0 + d1));
CHECK(a.index() == b.index()); // same words consumed, same order
CHECK(a.index() >= 2); // never fewer than two
CHECK(got >= 10 && got <= 17);
// an empty span still costs two words: half = 0 masks to 0, accepted at once.
MT19937 c(3u);
CHECK(c.int_range_bell(4, 4) == 4);
CHECK(c.index() == 2);
// and it is triangular, not uniform: the middle of [0,10] must beat the ends.
int hist[11] = {0};
MT19937 e(2024u);
for (int i = 0; i < 40000; ++i) ++hist[e.int_range_bell(0, 10)];
CHECK(hist[5] > hist[0] * 2);
CHECK(hist[5] > hist[10] * 2);
}
std::printf("test_rng: %s\n", fails ? "FAILED" : "ok");
return fails ? 1 : 0;
}