The challenge
A toolkit that spans R and Python faces a subtle obstacle. The two ecosystems use different default random-number generators (R’s Mersenne-Twister with inversion, NumPy’s PCG64), so a direct port produces different data from the same seed. That would undermine the guarantee that a design authored once can be reproduced anywhere.
A shared generator
pilotr sets aside the native RNGs and provides a shared generator
implemented identically in both languages. Uniform deviates come from
L’Ecuyer’s (1988) combined linear congruential generator, whose
arithmetic stays below
and is therefore exact in IEEE-754 doubles (R) and Python integers
alike. Normal deviates use Wichura’s (1988) Algorithm AS 241
inverse-CDF, the algorithm R’s qnorm() uses. The remaining
draws (Cholesky-correlated random effects, inverse-CDF Poisson and
ordinal draws, Marsaglia–Tsang gamma draws for the Beta family, a
Fisher–Yates item sampler for partial crossing) are derived from those
two through a documented, identical consumption order.
As a result, given the same specification and seed, the R and Python
implementations produce identical data sets wherever they share a maths
library, as on Linux. Continuous integration checks every case of the
parity harness to the last bit there. IEEE-754 fixes the rounding of
arithmetic but leaves that of exp(), log() and
pow() to the library, and on Windows R and Python do not
share one. There, a value that passes through those functions can differ
in its last bits, and every family has such a path. The figures below
were measured on Windows 11 with R 4.6.1 and Python 3.13.
- Normal draws pass through
log()in AS 241’s tail branch, which about 15% of draws take. About 1.4e-5 of normal draws differed, by 1 to 3 units in the last place (ulp), so Gaussian data can differ too. - Responses of the lognormal families and the ex-Gaussian differed by 1 ulp.
- An unrounded Beta response differed by up to 35 ulp.
- A discrete outcome can in principle move by a whole category, although none did in 50,000 rows of each discrete family.
One Beta case can differ on Linux too. From R 4.6.0, R’s
^ computes a cube or a fourth power by repeated
multiplication, where Python calls pow(). A Beta response
whose Gamma shape is exactly 1/3 or 1/4 can therefore differ in its last
bits on any platform. Such a shape needs a cell whose rows share one
linear predictor, as in a Beta design with no random effects, a linear
predictor of 0 and phi set to 0.5.
Rounding has removed every continuous difference observed so far, but
it guarantees nothing, since a last-bit difference can straddle a
rounding boundary, and round is refused for the discrete
families. build_spec(), which builds the no-code app’s
specifications, rounds the Gaussian and lognormal responses to four
decimals and leaves a Beta response unrounded, for the reason
?build_spec gives. Agreement to the last bit in every
family has one route: run both languages on a platform where they share
a maths library, outside that Beta case. The specification is the source
of truth, and every implementation answers to it. Its section Scope
of the guarantee gives the measurements in full.
Determinism in R
On one platform, the same specification and seed always give the same data.
spec <- build_spec(list(
name = "demo", seed = 2024, design_kind = "between", n_subject = 200,
factor_name = "group", lev1 = "a", lev2 = "b",
intercept = 100, effect = 5, family = "gaussian",
resp_name = "score", sigma = 10))
identical(simulate_design(spec), simulate_design(spec))[1] TRUE
Across operating systems, the same holds on the terms set out above,
since R takes exp(), log() and
pow() from the platform’s maths library, as Python
does.
Changing the seed changes the draws, while the structure and the ground-truth parameters stay fixed.
spec2 <- spec; spec2$seed <- spec$seed + 1L
identical(simulate_design(spec)$score, simulate_design(spec2)$score)[1] FALSE
The same design in Python
The Python package reads the same specification and produces the same data. The code below is not evaluated here. It shows the Python you would run, and its output matches the R data to the last bit wherever the two share a maths library.
from pilotr import simulate, load_spec
# the same design.json the R package (and the app) use
data = simulate("design.json")The repository includes a parity check that simulates the worked
example designs in both languages and reports the maximum absolute
difference per design. This is the CSV-level check, read at the
precision a written file carries and with a tolerance of 1e-6. The
stricter one is the harness under tools/parity/, which
dumps every value at 17 significant digits, accounts for each differing
cell in units in the last place against a recorded allowance, and runs
as a gate in continuous integration. The table below is the readable
summary, and that harness is what the bit-identical claim rests on.
| design | rows | columns | max_abs_diff | categorical_mismatches |
|---|---|---|---|---|
| between | 64 | 3 | 0 | 0 |
| crossed | 1440 | 4 | 0 | 0 |
| continuous | 4000 | 7 | 4.885e-15 | 0 |
| nested | 4800 | 5 | 0 | 0 |
| beta | 200 | 3 | 0 | 0 |
| partial | 1440 | 4 | 0 | 0 |
The difference is exactly zero for every design whose responses are
rounded, and about 5e-15 for the unrounded continuous design, where R’s
CSV writer prints 15 significant digits. At that precision the residual
cannot be told apart from the writer’s own rounding, and it is what the
1e-6 tolerance of the check is there to absorb. The stricter harness
dumps 17 digits and resolves what is really there. On Windows, it finds
a handful of cells one unit in the last place apart, from
exp(), and no categorical mismatches. On Linux, where it
runs in continuous integration, every cell agrees. The uniform stream is
exact by construction, since every intermediate product stays below
.
The harness pins the Gaussian cases to recorded hashes in
tools/parity/golden.json, on the observed agreement of AS
241’s log() across platforms. The cases are small enough
that none has met one of the rare normal draws that differ. The cases
whose values pass through exp(), log() or
pow() on the way to the response are deliberately left
unpinned, because those last bits belong to the maths library and so are
not the specification’s to fix. The cross-language comparison still
gates them on every run. The parity check can be rerun on any new
platform, so a design verified on a laptop can be confirmed on an HPC
node before a large run.
The analysis layer
Everything above concerns the data the generator produces.
solve_curve() sits a layer above it, taking a curve that
has already been simulated and fitting a model to it, and it is held to
a different standard on purpose. Its fit calls exp() and
the normal distribution function at every iteration, so the two engines
cannot be asked to land on the same bits, and a recorded hash would pin
whichever maths library happened to record it. What they are asked for
is agreement far closer than any reading of the result could depend on.
The script tools/parity/solve_cross.py puts the same fixed
curves through both engines and compares the solved value, its interval,
the fitted coefficients and the text of every refusal. The largest
relative difference measured over those cases is 5.0e-15, against an
allowance of 1e-9. On a solved sample size of 220 subjects that
allowance comes to two ten-millionths of a subject. Every refusal
message is identical character for character.
The mixed models of the two power_mixed() functions come
from model_formula() and model_data(), which
the Python package ports from this one. The script
tools/parity/formula_cross.py gives the Python ports the
data R simulated for every shipped example and parity case. Both must
write the same formula character for character and the same contrast and
product columns bit for bit. The fits that follow are another matter.
lme4 and statsmodels are different programs,
this package estimates the random-effect correlations that the Python
one leaves out, and the two test with different statistics. The Python
power guide sets out where their results diverge.
power_report() writes a sample-size justification from
the counts of a result, the significant replicates and the replicates
behind each rate. It computes each Monte Carlo error with the functions
that compute them in both packages, which return the same doubles for
the same counts, and formats them in the same way. The script
tools/parity/report_cross.py renders a set of stored
records in both engines and requires the same text, byte for byte.
Why this matters
Because the specification is the source of truth and the generator is shared, a design can be authored once (in the app, in R or in Python) and reproduced everywhere. It can be preregistered as a small JSON file that anyone can run, scaled from a laptop to a cluster and handed between collaborators who work in different languages, with data that agree to the last bit wherever the machines share a maths library.
This property turns an informal request to simulate some data into a reproducible, shareable methodological artefact.