diff --git a/docs/mars-rng.md b/docs/mars-rng.md index 143f012..e7a7ea3 100644 --- a/docs/mars-rng.md +++ b/docs/mars-rng.md @@ -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 diff --git a/src/mars/rng/mt19937.h b/src/mars/rng/mt19937.h index ece08d0..3f7763c 100644 --- a/src/mars/rng/mt19937.h +++ b/src/mars/rng/mt19937.h @@ -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(y) * kUnitScale; } static float float_from(uint32_t y) { return static_cast(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(); }; diff --git a/tests/mars_stream/test_rng.cpp b/tests/mars_stream/test_rng.cpp index 3105ba5..c7a5221 100644 --- a/tests/mars_stream/test_rng.cpp +++ b/tests/mars_stream/test_rng.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #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::quiet_NaN(); + CHECK(e.chance(nan) == false); + CHECK(e.index() == 1); + } std::printf("test_rng: %s\n", fails ? "FAILED" : "ok"); return fails ? 1 : 0; }