Skip to content

test: make the seeded RNG independent of the C++ standard library - #178

Merged
danmcleran merged 1 commit into
masterfrom
fix/portable-test-rng
Aug 31, 2026
Merged

danmcleran merged 1 commit into
masterfrom
fix/portable-test-rng

Conversation

@danmcleran

Copy link
Copy Markdown
Owner

Addresses the RNG divergence surfaced by the nightly MSan job in #177.

The problem

Several suites initialize network weights from a seeded RNG, then assert on what training converges to. std::uniform_real_distribution does not specify the mapping from engine output to value, and std::default_random_engine is an implementation-chosen typedef — so libstdc++ and libc++ return different doubles from identical engine state. Different weights, different trained results, failing tolerances. Not a defect; a portability hole.

The fix

unit_test/include/portable_test_random.hpp freezes the sequence the tests were already written against, in portable code: minstd_rand0 (x = 16807x mod 2^31-1) plus the generate_canonical<double, 53> mapping libstdc++ layers on top, which for this engine consumes exactly two draws.

Verified bit-identical to libstdc++ over 20,000 draws — 0 mismatches, max difference 0. So every tolerance keeps its current meaning and not one needed changing.

Why not std::mt19937

That was the first choice — its output is standard-specified — and it was rejected on evidence. It is statistically indistinguishable from the current generator (mean ~0, mean|x| ~0.5 over 2000 draws), but it produces a different sequence, and two tests do not survive that:

Test Result Bound
test_case_lstm_weight_serialization 0.605 0.02
test_case_rmsprop_fixedpoint_xor avg error 9 4

Loosening those to accommodate a new generator would weaken two real gates to settle a question neither is asking about.

Locked against silent drift

test_case_portable_uniform_real_matches_frozen_sequence asserts the first eight draws exactly. Every training test here is calibrated against those numbers, so a future edit to the generator would otherwise silently re-tune all of them. Verified to have teeth by perturbing an expected value and confirming it fails.

Verification

Suite g++ clang++
unit_test/nn pass pass
unit_test/kan pass pass
unit_test/qlearn pass pass

Left alone, deliberately

qlearn's std::uniform_int_distribution for maze state selection has the same implementation-defined mapping. It is documented rather than changed: the suite passes under the instrumented libc++ today, so there is no observed problem, and changing a passing suite's draw sequence risks its assertions for no benefit.

🤖 Generated with Claude Code

https://claude.ai/code/session_019tVgXeXCcfbHqfMjhufWzd

The nightly MSan job links an instrumented libc++, and under it unit_test/nn
failed two tolerance checks that pass under libstdc++. Not a defect: several
suites initialize network weights from a seeded RNG and then assert on what
training converges to, and std::uniform_real_distribution does not specify the
mapping from engine output to value. libstdc++ and libc++ return different
doubles from identical engine state, so the weights differ and trained results
move. std::default_random_engine is likewise an implementation-chosen typedef.

unit_test/include/portable_test_random.hpp freezes the sequence the tests were
written against, in portable code: minstd_rand0 (x = 16807x mod 2^31-1) plus
the generate_canonical<double, 53> mapping libstdc++ applies over it, which for
this engine consumes exactly two draws. Verified bit-identical to libstdc++
over 20,000 draws -- 0 mismatches, max difference 0 -- so every tolerance in
these suites keeps its current meaning and not one needed changing.

std::mt19937, whose output the standard does specify, was the first choice and
was rejected on evidence. It is statistically indistinguishable (mean ~0,
mean|x| ~0.5 over 2000 draws) but produces a different sequence, and two tests
do not survive that: lstm_weight_serialization at 0.605 against a 0.02 bound,
and rmsprop_fixedpoint_xor at average error 9 against a bound of 4. Loosening
those to accommodate a new generator would weaken two real gates to settle a
question neither is asking about.

test_case_portable_uniform_real_matches_frozen_sequence locks the first eight
draws as an exact assertion. Every training test here is calibrated against
those numbers, so a future edit to the generator would otherwise silently
re-tune all of them; now it fails loudly. Verified to have teeth by perturbing
an expected value and confirming the failure.

Applied to unit_test/nn, unit_test/kan and unit_test/qlearn. All three pass
under both g++ and clang++.

qlearn's std::uniform_int_distribution use for maze state selection is left
alone and documented: that mapping is implementation-defined too, but the suite
passes under the instrumented libc++ today, and changing a passing suite's draw
sequence risks its assertions for no observed benefit.
@danmcleran
danmcleran merged commit 207bfa0 into master Aug 31, 2026
24 checks passed
@danmcleran
danmcleran deleted the fix/portable-test-rng branch August 31, 2026 05:06
danmcleran added a commit that referenced this pull request Aug 31, 2026
#178 converted unit_test/nn/nn_unit_test.cpp and
unit_test/qlearn/qlearn_unit_test.cpp from CRLF to LF as a side effect of being
edited through Python's text mode, which normalizes newlines on write. Both
files have been CRLF for their whole history.

The content change in that PR was 33 added and 8 removed lines in nn, and 7
added and 10 removed in qlearn. The recorded diff was 17011 and 1959 lines,
because every line in both files counted as rewritten. That buries the real
change, makes the PR unreviewable, and poisons git blame for two of the largest
files in the tree.

Converts both back. No content change: the suites build and pass unchanged.
kan_unit_test.cpp was already LF and is untouched.
danmcleran added a commit that referenced this pull request Aug 31, 2026
Editing a CRLF file with a tool that normalizes newlines rewrites the whole
file. In #178 a +33/-8 change to nn_unit_test.cpp was recorded as 17,011 lines,
which buried the real change and poisoned git blame; #179 undid it by hand.
Nothing in CI catches this -- line endings do not affect compilation, so every
gate stays green while the diff is unreviewable.

This makes the mistake structurally impossible rather than a matter of who
edits next. `* text=auto` has git store LF in the repository, and the files
that are CRLF in the working tree -- the original core headers, the first
examples, the two oldest test suites, the UML sources and one dataset -- are
pinned with `text eol=crlf` so checkout still gives them CRLF. Everything added
since is LF and needs no entry.

The effect is that a tool which converts endings now produces no diff at all:
the index holds LF either way, and checkout restores the working-tree
convention. Verified by repeating the #178 mistake against these rules -- the
same operation that produced +8518/-8518 now produces nothing, and checkout
restores CRLF.

This commit therefore renormalizes what the repository stores for those files.
That is a one-time whole-file diff for each, and the last one they will have
for this reason. Checked two ways first: every staged change is line-endings
only (content compared with CR stripped), and no file's working-tree endings
change.
danmcleran added a commit that referenced this pull request Aug 31, 2026
Editing a CRLF file with a tool that normalizes newlines rewrites the whole
file. In #178 a +33/-8 change to nn_unit_test.cpp was recorded as 17,011 lines,
which buried the real change and poisoned git blame; #179 undid it by hand.
Nothing in CI catches this -- line endings do not affect compilation, so every
gate stays green while the diff is unreviewable.

This makes the mistake structurally impossible rather than a matter of who
edits next. `* text=auto` has git store LF in the repository, and the files
that are CRLF in the working tree -- the original core headers, the first
examples, the two oldest test suites, the UML sources and one dataset -- are
pinned with `text eol=crlf` so checkout still gives them CRLF. Everything added
since is LF and needs no entry.

The effect is that a tool which converts endings now produces no diff at all:
the index holds LF either way, and checkout restores the working-tree
convention. Verified by repeating the #178 mistake against these rules -- the
same operation that produced +8518/-8518 now produces nothing, and checkout
restores CRLF.

This commit therefore renormalizes what the repository stores for those files.
That is a one-time whole-file diff for each, and the last one they will have
for this reason. Checked two ways first: every staged change is line-endings
only (content compared with CR stripped), and no file's working-tree endings
change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant