# 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`](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 reproduce `numpy.random.default_rng(seed)` bit-for-bit (`uint64_fill ≡ default_rng(s).integers`, `standard_normal_fill ≡ ...`). - **The config has NO rng-kind selector.** `cfg` exposes `solver`, `deflation`, scaling, etc., but no `n4m_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_seed` in `pls.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 | |------|----------------------|------------------| | `registry` / `cpp` / `python_tier1` | **call R `plsVarSel::mcuve_pls`** (the canonical path is `legacy=False` → R) | `[2,5,8,13]` | | `ref_r_plsvarsel` | R `mcuve_pls` (oracle) | `[2,5,8,13]` | | `r_tier1` / `matlab_tier1` / `python_tier2` | **the n4m C kernel `n4m_uve_select`** | `[2,5,8]` | 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: 1. **Noise model** — n4m adds a fixed `noise_features` count of signed-uniform noise standardised per column; R adds `ncol(X)` uniform-noise columns. 2. **RNG** — n4m splitmix64 vs R Mersenne-Twister (`set.seed(11)`). 3. **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.seed` scrambler, R's `rnorm` inversion) — 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*: 1. **R-compatible Mersenne-Twister.** R's RNG is MT19937 but with R's own `set.seed` scrambling (Init_R_seed) and R's `unif_rand`/`norm_rand` (inversion via `qnorm`). This is NOT the same byte stream as numpy's `RandomState` (also MT but seeded differently) nor generic MT. It is well-documented (R `src/main/RNG.c`) but fiddly — ~a few hundred LOC + an exact-stream test against R. 2. **Exact noise model** — replicate how R builds its noise columns (count, distribution, placement). 3. **Exact draw order** — the sequence of RNG consumption must match R's loop structure draw-for-draw; one extra/missing draw desynchronises everything. 4. **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_seed` in `pls.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 the `legacy=True` opt-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; correctly `not_available` for 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. ## 3c. Codex review corrections (verified vs source — authoritative over §3b) A read-only Codex review (high reasoning) checked the analysis against the code and corrected three factual points + added two risks: 1. **R-referenced set ≥ 12 and `iriv_select` is NOT R-backed.** R-default selectors include stability, uve, spa, cars, ga, shaving, bve, emcuve, randomization, rep, ipw, st, wvc_threshold (registry.py:3588/3845/3957/4867/ 5001/5119/5244/5381). `iriv_select` routes through a **NumPy port using `default_rng`** (registry.py:4485, 7659), not R. 2. **auswahl is NOT numpy-PCG64.** It passes `random_state=int(seed)` (registry.py:1562/4014/7543); auswahl 0.9.0 uses sklearn `check_random_state` → legacy **`numpy.random.RandomState` (MT19937)**, not `default_rng`/PCG64. The auswahl cases need a THIRD engine (numpy RandomState-MT); the "cheap PCG64 Tier A" is smaller/different than hoped. 3. **`n4m.h:344` overclaims PCG64 adoption** — UVE/EMCUVE public APIs take only a seed (pls.h:1404/1605) and UVE uses file-local splitmix64 (uve_selection.cpp:144/215). The header comment needs correcting. 4. **RISK (Tier B): symbol-additive ≠ behaviour-preserving.** splitmix is duplicated file-local across ~10 selectors with method-specific seed perturbations (EMCUVE `noise_seed + ensemble*golden` emcuve_selection.cpp:87; randomization per-permutation seed :281; randomized-SVD context-seed offset model.cpp:766). **Freeze current splitmix streams with golden tests BEFORE the vtable refactor**; default `rng_kind` must = exact-current-splitmix. 5. **RISK (Tier C): R-MT is bounded to `set.seed`+`unif_rand`(+`rnorm`).** Methods that use R's `sample()`, CV-segment generation or package internals also need `sample.kind` semantics + package draw order — NOT "a few hundred LOC". The R-MT engine just landed covers the bounded case only. Also: some references are "closest installable analog", not the method proper (CARS vs `enpls.fs`, registry.py:6061) → keep those as documented `cross_check`/Jaccard, never a forced bit-match. **Revised verdict (Codex-aligned), supersedes §5:** - **Tier 0 first** — fix the method inventory; per method decide canonical / compat-mode / documented-cross_check (§3b was unreliable). - **Tier B only after golden stream tests** freeze current splitmix; `rng_kind` additive, default = current splitmix. - **Implement only the RNGs actually needed:** PCG64 exists; add numpy `RandomState`-MT for auswahl; R-MT (DONE) for true R-compat methods. - **Tier C only for high-value methods** where the reference IS the target algorithm (UVE/MCUVE); cross_check/Jaccard for the analog-only ones. ## 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 + bump `N4M_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) 1. **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. 2. **Tier A** likely converts the numpy/sklearn-referenced stochastic methods to exact repro quickly once Tier B exists. 3. **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. 4. **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?