merge lane I: MT19937 float_range/range_from/int_range_bell; RNG entry-point word-cost table
This commit is contained in:
commit
bcf429740c
4 changed files with 148 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (;;) {
|
||||
|
|
|
|||
|
|
@ -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)); }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue