rng: model chance(), the only entry point that can cost zero words

Both early-outs return without touching the generator: p <= 0 false, p >= 1
true, otherwise exactly one word compared with a strict <. The zero-cost cases
decide stream alignment wherever a caller's probability climbs -- the spy
counter-mission adds 0.2f per failed turn and stops drawing entirely from the
fifth. A model without the early-outs drifts one word from there on, for ever.

NaN takes neither early-out in the original, so it draws and returns false;
reproduced rather than smoothed over.
This commit is contained in:
alex 2026-09-08 22:32:14 -04:00
parent 35b05ebc18
commit 989c692c53
3 changed files with 82 additions and 0 deletions

View file

@ -112,6 +112,20 @@ Three consequences worth stating separately, because each one is a way a ledger
fraction of words; `test_rng.cpp` asserts the two models are distinguishable so the
shortcut cannot creep back in unnoticed.
**The probability test (`chance`) is the only entry point that can cost nothing, and that is
load-bearing.** `p <= 0` returns false and `p >= 1` returns true, each without touching the
generator; in between it spends exactly one word and compares with a **strict** `<`. The zero-cost
cases decide stream alignment wherever a caller's probability *climbs*: the spy counter-mission
adds `0.2f` per failed turn, so it spends a word for four turns and then nothing at all from the
fifth on. A model without the early-outs is one word out of step from that point forward, for
ever — and one word of drift is the whole determinism claim.
NaN is deliberately not special-cased. Both early-out tests are written in the original as pairs
of ordered comparisons, which a NaN makes false, so a NaN probability falls through, **draws a
word**, and returns false. That word is reproduced here: a caller that computes a NaN probability
really does perturb the stream, and smoothing it over would hide a genuine divergence rather than
prevent one.
`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

View file

@ -80,6 +80,32 @@ public:
// the first bound huge rather than empty; that is reproduced, not corrected.
int32_t int_range_bell(int32_t lo, int32_t hi);
// A weighted coin, and the only entry point that can cost ZERO words. Both
// early-outs return without touching the generator:
//
// p <= 0 -> false, no draw
// p >= 1 -> true, no draw
// else -> next_float() < p (STRICT less-than), exactly one word
//
// The zero-cost cases are not a detail. A caller that accumulates its
// probability -- the spy counter-mission adds 0.2f per failed turn -- spends a
// word for four turns and then NOTHING on the fifth, and a reimplementation
// without the early-out is one word out of step from there on, permanently.
// The comparison direction matters for the same reason: `<` and `<=` differ on
// the measure-zero case where the drawn float lands exactly on p, and the
// original uses `<`.
//
// NaN is deliberately not special-cased. A NaN p fails both early-out tests in
// the original (each is written as a pair of ordered comparisons that a NaN
// makes false), so it DRAWS A WORD and then returns false, since `u < NaN` is
// false. That word is reproduced here: a caller that computes a NaN
// probability perturbs the stream, and hiding that would hide a real divergence.
bool chance(float p) {
if (!(p > 0.0f)) return p != p ? draw_and_compare(p) : false;
if (p >= 1.0f) return true;
return draw_and_compare(p);
}
// --- 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)); }
@ -109,6 +135,9 @@ private:
uint32_t mt_[N];
int left_ = 0;
// The drawing half of chance(): always one word, strict `<`.
bool draw_and_compare(float p) { return next_float() < p; }
void twist();
};

View file

@ -2,6 +2,7 @@
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <limits>
#include "mars/rng/mt19937.h"
@ -248,6 +249,44 @@ int main() {
CHECK(hist[5] > hist[0] * 2);
CHECK(hist[5] > hist[10] * 2);
}
{
// chance(): the only entry point that can cost ZERO words, and the zero
// cases are the whole point -- a caller whose probability climbs past 1
// stops perturbing the stream, and a model without the early-outs drifts
// one word per such call, permanently.
MT19937 a(5489u);
CHECK(a.chance(0.0f) == false);
CHECK(a.chance(-1.0f) == false);
CHECK(a.chance(1.0f) == true);
CHECK(a.chance(2.0f) == true);
CHECK(a.index() == 0); // four calls, not one word spent
// in (0,1) it is exactly one word, and the comparison is STRICT `<`
// against the same float the generator would hand any other consumer.
MT19937 b(5489u), c(5489u);
const float u = c.next_float();
CHECK(b.chance(0.5f) == (u < 0.5f));
CHECK(b.index() == 1);
CHECK(b.index() == c.index());
// the accumulating case the spy counter-mission actually runs: 0.2f,
// 0.4f, 0.6f, 0.8f each draw, then p >= 1 costs nothing for ever after.
MT19937 d(1234u);
float p = 0.2f;
for (int i = 0; i < 4; ++i, p += 0.2f) (void)d.chance(p);
const int spent = d.index();
CHECK(spent == 4);
for (int i = 0; i < 10; ++i) CHECK(d.chance(p) == true);
CHECK(d.index() == spent); // ten more calls, still four words
// NaN takes neither early-out in the original -- each is a pair of ordered
// comparisons a NaN makes false -- so it DRAWS and returns false. The word
// is reproduced deliberately: hiding it would hide a real divergence.
MT19937 e(77u);
const float nan = std::numeric_limits<float>::quiet_NaN();
CHECK(e.chance(nan) == false);
CHECK(e.index() == 1);
}
std::printf("test_rng: %s\n", fails ? "FAILED" : "ok");
return fails ? 1 : 0;
}