RNG parity for stochastic methods — feasibility analysis¶
Question (from maintainer): make the stochastic methods reproduce their external references exactly (Jaccard 1.0 for selectors). Do it properly: make the RNG an optional model parameter, implement additional RNG algorithms beyond splitmix64 to cover the tests, and initialise the tests with the right RNG + seed.
This document analyses impact, feasibility, and effort, and proposes a tiered plan. It is the basis for a Codex review.
Status update (2026-05-30)¶
This analysis is retained for rationale, but it is no longer the current task
list. The additive RNG infrastructure has shipped (n4m_rng_kind_t with
splitmix64, PCG64, R-MT, and numpy RandomState-MT), the UVE R-exact pilot is
available behind rng_kind=MT_R, and the dashboard now renders documented
selector RNG/noise/model mismatches as cross_check/BD J instead of red
numeric divergences. See
RNG_TIER0_INVENTORY.md for the authoritative
post-investigation verdict table.
1. What we have today (verified in source)¶
Two RNGs already exist in the core:
splitmix64— drives binary-mask / index generators in the selectors (hardcoded inside each method).n4m_rng_pcg64_*(public ABI,cpp/include/n4m/n4m.h:367+) — documented to reproducenumpy.random.default_rng(seed)bit-for-bit (uint64_fill ≡ default_rng(s).integers,standard_normal_fill ≡ ...).
The config has NO rng-kind selector.
cfgexposessolver,deflation, scaling, etc., but non4m_config_set_rng_kind. RNG choice is currently baked into each method’s C++.~14 seed-taking parameters across the public PLS/selector ABI (
uint64_t seed | noise_seed | randomization_seedinpls.h): the stochastic surface = the variable selectors (uve, emcuve, cars, random_frog, spa-ish, ga, pso, vissa, irf, randomization, stability, scars, …) plus the ensemble PLS (bagging, boosting, random_subspace) and some augmenters.
2. The real root cause (uve_select, worked example)¶
The dashboard “Jaccard 0.75” for uve_select is NOT a binding bug:
Tier |
What it actually runs |
Selected indices |
|---|---|---|
|
call R |
|
|
R |
|
|
the n4m C kernel |
|
So the comparison is n4m’s own C kernel vs R mcuve_pls, and they differ by
one boundary variable (index 13). Per the registry docstring
(_uve_select_pls4all), the C kernel deliberately differs from R in three
independent ways:
Noise model — n4m adds a fixed
noise_featurescount of signed-uniform noise standardised per column; R addsncol(X)uniform-noise columns.RNG — n4m splitmix64 vs R Mersenne-Twister (
set.seed(11)).Fold / CV semantics — contiguous-fold coefficient sampling vs R’s.
Consequence: matching R bit-for-bit needs ALL THREE aligned, not just the RNG. With a different noise model or draw order, even an identical RNG stream diverges. “Make the RNG a parameter” is necessary but far from sufficient.
This generalises: each stochastic method’s reference is one of
R (Mersenne-Twister, R’s
set.seedscrambler, R’srnorminversion) — most selectors (plsVarSel,mdatools),numpy/sklearn (PCG64 — which n4m ALREADY reproduces) — the python-composite methods,
auswahl (numpy under the hood) — random_frog / vissa / irf / vip_spa,
deterministic (no RNG) — spa, vip, coefficient, interval.
3. Why exact repro is hard (the honest part)¶
Bit-exact match to R requires, per method:
R-compatible Mersenne-Twister. R’s RNG is MT19937 but with R’s own
set.seedscrambling (Init_R_seed) and R’sunif_rand/norm_rand(inversion viaqnorm). This is NOT the same byte stream as numpy’sRandomState(also MT but seeded differently) nor generic MT. It is well-documented (Rsrc/main/RNG.c) but fiddly — ~a few hundred LOC + an exact-stream test against R.Exact noise model — replicate how R builds its noise columns (count, distribution, placement).
Exact draw order — the sequence of RNG consumption must match R’s loop structure draw-for-draw; one extra/missing draw desynchronises everything.
Exact CV / fold semantics and the exact thresholding rule.
(2)–(4) amount to porting each R function’s algorithm into C++. That is the bulk of the effort and the risky part — some references (auswahl, mdatools) have undocumented internals.
There is also a design tension: n4m’s C kernels were deliberately built with their OWN deterministic, documented noise model (so they are reproducible and not hostage to R’s RNG). Making them mimic R means abandoning n4m’s own design to copy R bit-for-bit. That is a legitimate choice for “dashboard shows green”, but it is “n4m becomes R”, not “n4m is independently correct”.
3b. Concrete classification (verified by grep over registry source)¶
13 seed-taking methods in the public ABI (
uint64_t seed|noise_seed| randomization_seedinpls.h).15 registry helper functions route a selector to R (
_*_indices_via_r): bve, cars(enpls), emcuve, ga, ipls/interval, ipw, randomization, rep, shaving, spa, sr, stability/mcuve, st, uve, wvc_threshold.3 route to auswahl (numpy): random_frog, vip_spa, irf.
12 methods have their DEFAULT path (
legacy=False) routing to R — i.e. for these 12 the “canonical n4m” value shown on the dashboard is R’s output, and the n4m C kernel is thelegacy=Trueopt-in that the binding tiers exercise. This is the set where C-kernel-vs-R parity is measured.The numpy/sklearn composites (
bagging/boosting/random_subspace/n_pls/ pls_qda/pls_glm/pls_cox/pls_logistic/group_sparse) BYPASS the C kernel entirely — NOT an RNG problem; correctlynot_availablefor C-kernel bindings.
Net (corrected): the RNG-exact-repro effort is ~12–15 R-referenced selectors, not the 6 I first estimated — i.e. Tier C is larger than my first pass. The “cheap PCG64 win” (Tier A) is genuinely small, because the numpy-referenced stochastic surface is mostly composites the C kernel does not run. Tier B (RNG abstraction) is the enabler for the 12–15 R cases + 3 auswahl cases.
4. Proposed tiered plan¶
Tier B (infrastructure — do this regardless; it is the right abstraction)¶
Make RNG a first-class, pluggable, configurable concept:
typedef enum n4m_rng_kind_t { N4M_RNG_SPLITMIX64=0, N4M_RNG_PCG64=1, N4M_RNG_MT19937_R=2, ... }in a public header.A core RNG interface (vtable:
next_u64,next_double,next_normal,reseed) so methods draw from an abstract RNG, not a hardcoded one.n4m_config_set_rng_kind(cfg, kind)+ reuse the existing seed.Wire each of the ~14 stochastic methods to draw from the configured RNG.
ABI impact: new public symbols → regenerate
expected_symbols_*on all 3 platforms + bumpN4M_ABI_VERSION_*+docs/abi/changes_log.md. The enum and setter are additive (backward-compatible), default = current behaviour (splitmix64) so nothing silently changes.Effort: ~1–2 weeks C++ + ABI gates + per-binding plumbing (R/MATLAB/Py/JS must expose the new setter). Self-contained, testable, valuable on its own.
Tier A (cheap wins — do first)¶
For methods whose reference is numpy/sklearn, n4m ALREADY has PCG64. If a method currently uses splitmix64 but its reference uses numpy, switching it to the existing PCG64 RNG (via Tier B’s config, or directly) can yield exact repro cheaply — no new RNG needed. Audit which of the ~14 fall in this bucket.
Tier C (expensive — per-method R alignment; decide method-by-method)¶
Implement N4M_RNG_MT19937_R (R-compatible MT + rnorm inversion), then for
each R-referenced selector replicate R’s noise model + draw order + folds +
threshold. Effort: days per method × ~10–12 methods = weeks→months, with no
guarantee for the undocumented ones (auswahl/mdatools internals).
5. Recommendation (to be reviewed by Codex)¶
Tier B is worth doing on its own merits — “RNG as a configurable, pluggable model parameter” is the correct architecture and unblocks everything else. Ship it with
default = splitmix64(no behaviour change) and PCG64 already wired.Tier A likely converts the numpy/sklearn-referenced stochastic methods to exact repro quickly once Tier B exists.
Tier C is a per-method decision, not a blanket commitment. For each R-referenced selector, weigh “port R’s algorithm into the C kernel” vs “accept it as a documented
cross_check(n4m has its own valid algorithm)”. Bit-exact-to-R is achievable but is real reverse-engineering and partly makes n4m a re-implementation of R.Independent of all the above, the dashboard must stop showing a Jaccard overlap as if it were an RMSE divergence (separate task #3, in progress) — a Jaccard 0.75 is “75% feature overlap”, not “0.75 numeric drift”.
6. Open questions for the maintainer¶
For the R-referenced selectors, is the goal “n4m reproduces R exactly” (Tier C, n4m mimics R) or “n4m has a documented, correct selection that we display honestly as a cross-check”? This is the cost driver.
Is the new RNG abstraction (Tier B) acceptable as an additive ABI change with an ABI-version bump and snapshot regen on all platforms?