diff --git a/docs/mars-rng.md b/docs/mars-rng.md index c4ad740..143f012 100644 --- a/docs/mars-rng.md +++ b/docs/mars-rng.md @@ -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 diff --git a/src/mars/rng/mt19937.cpp b/src/mars/rng/mt19937.cpp index effa038..1f54d59 100644 --- a/src/mars/rng/mt19937.cpp +++ b/src/mars/rng/mt19937.cpp @@ -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(hi) - static_cast(lo); + const float scaled = static_cast(span * unit_from(y)); + return static_cast(static_cast(lo) + static_cast(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(half)); + const uint32_t b = next_int_inclusive(static_cast(h - half)); + return static_cast(static_cast(lo) + a + b); +} + uint32_t MT19937::next_int_inclusive(uint32_t n) { const uint32_t mask = cover_mask(n); for (;;) { diff --git a/src/mars/rng/mt19937.h b/src/mars/rng/mt19937.h index e759630..ece08d0 100644 --- a/src/mars/rng/mt19937.h +++ b/src/mars/rng/mt19937.h @@ -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(y) * kUnitScale; } static float float_from(uint32_t y) { return static_cast(unit_from(y)); } diff --git a/tests/mars_stream/test_rng.cpp b/tests/mars_stream/test_rng.cpp index 0cac1ad..3105ba5 100644 --- a/tests/mars_stream/test_rng.cpp +++ b/tests/mars_stream/test_rng.cpp @@ -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(-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; }