Changelog¶
All notable changes to this project are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased¶
Added¶
- Specification version 0.4 allocates several between factors on one unit jointly, so a 2 × 2
between-subjects design over 40 subjects has four cells of 10. The units fall into one block
per combination of levels, with the first-listed factor varying slowest, and nothing is drawn.
A specification using this must declare
"spec_version": "0.4", a version pilotr 0.3.0 refuses to read. One that does not still gets the refusal described under Changed, which names both remedies. A 0.4 specification whosevary_withinomits a unit of the design is refused.SPEC_VERSIONis now "0.4", the newest version this release reads. The R package'sbuild_spec()andspec_from_model()declare the lowest version a specification needs, so the specifications the no-code app exports stay readable by pilotr 0.3.0 unless they use a 0.4 feature. counterbalanceon a within factor gives each subject one condition per item, rotating the lists across subjects and items, as in a Latin-square design. Planning such a study with a fully crossed specification doubled the observations per participant and overstated power. Subject s meets item t in condition ((s − 1) + (t − 1)) mod C, where C counts the conditions, or the combinations of levels when several factors are counterbalanced, and nothing is drawn. The field belongs to specification version 0.4, needs an item unit and is refused withper_subject. A warning, in the R twin's words, says when the lists are unequal, because the subjects or the items are not a multiple of C. The condition stays a within factor, sopower_mixedanalyses a counterbalanced design, as it does the two-list encoding, which stays documented for releases that read no version above 0.3.- The simulated data of a counterbalanced specification name each subject's list in a column
list, placed aftersubjectanditem. Subject s follows list ((s − 1) mod C) + 1, and nothing is drawn for it, so a specification without a counterbalanced factor simulates as before. Such a design keeps the namelistfor that column, andvalidate_specrefuses a factor, predictor, grouping factor, contrast column or response of that name in it, in the R twin's words. - Every rate carries its Monte Carlo error.
powerandpower_mixedgainpower_mcse,power_loandpower_hi, the standard error and 95% Wilson interval of power. Both also gaintype_m_mcse, the standard error of Type M, and a Wilson interval for Type S,type_s_loandtype_s_hi, since the standard error of Type S is zero whenever no sign is wrong. Eachpower_curverecord gains the same keys andtype_s, which it lacked. Type S and Type M rest on the significant replicates alone, which are often a handful, yet they were reported without any uncertainty. The values are computed as in the R twin, operation for operation, so the same counts give the same doubles in both languages, and both test suites pin them. The power guide draws the band around its curve frompower_loandpower_hi, where it had computed a Wald band by hand, which narrows to nothing where power reaches 0 or 1. power,power_curveandpower_mixedgainalternative, which plans the one-sided test of a directional prediction with"greater"or"less". Every earlier result was two-sided, and the default,"two.sided", leaves every number as it was. The values are spelt as in the R twin, and scipy's"two-sided"is refused, so that a call means the same in both languages. The one-sided p-value comes from the statistic behind the two-sided one, the t ofscipy.stats.ttest_indor the Wald z of MixedLM. Type S and Type M are taken over the replicates significant in the tested direction.powerrecords the test in a new key,alternative, and thebackendofpower_mixednames the direction. On the same replicates, a one-sided test at alpha rejects exactly where the two-sided test at 2 alpha rejects with the estimate in the tested direction. A two-sided result at 2 alpha from an earlier version therefore gives the one-sided power at alpha, in the direction of the true effect, aspower * (1 - type_s).power_mixedfits the model the specification implies, as the R twin does.model_formulaandmodel_data, ports of the R functions, write that model in lme4's notation and build the columns it reads. Every coefficient is a fixed effect, an interaction"a:b"entering as its product columna_b, and every random intercept and slope the specification declares is a variance component, those of an extra grouping factor such as a site included. Up to 0.3.0 the model wasyv ~ ccwhatever the specification declared, with by-subject and by-item intercept and slope components and a test of the first contrast of a single within factor, and any other design was refused. A 2 × 2 within design raisedNotImplementedError, and onreading_time_continuoussix of the seven coefficients, the three predictors and the two by-subject slopes were left out. Both now run, and so does any design that declares a random effect, with or without an item unit.tools/parity/formula_cross.pygives the ports the data R simulated for every shipped example and parity case and requires R's formulas character for character and R's contrast and product columns bit for bit. The parity job in CI runs it. A replicate ofreading_time_continuoustakes about 7 s with NumPy held to one thread. The random-effects design is held in sparse matrices from 100,000 entries, rows times random coefficients, which made a replicate of the three largest crossed examples 1.4 to 4.5 times quicker, and in dense ones below, where sparse ones were about five times slower.power_mixedgainsfocal, which chooses the effects to test, as in the R twin. By default it takes every coefficient, with the true value the specification gives it. A list of names leaves the true values unknown, and a dict from names to numbers gives them. Each effect's results sit under a new key,effects, one entry per focal effect with the twelve keys frompowertotype_m_mcsethat the result held for its one effect. A focal name that the model lacks draws the R twin's warning, word for word.power_reportwrites a sample-size justification in Markdown from a power result and, optionally, a solved curve. The guides called the sample size fromtarget_nthe number a preregistration quotes, but the justification behind it had to be assembled by hand. The report gives the items Lakens (2022, https://doi.org/10.1525/collabra.33267, Table 4) asks for. It states the primary analysis with its test, alpha and sidedness, and the desired power with the solved sample size and its interval. Each focal effect follows with the justification given for its size, andnull_resultgives the power when every focal effect is zero. The simulation is accounted for as Morris, White and Crowther (2019, https://doi.org/10.1002/sim.8086) recommend, with the replicates, the fit counts and the Monte Carlo standard error and Wilson interval of every rate, computed in the report from the counts. The report ends with the software versions, the specification's seed and an MD5 checksum of the specification file's bytes. Apower_mixedresult is read fromeffects, so its deprecated keys are not touched. The R twin'spower_report()writes the same text, byte for byte, from the same counts, whichtools/parity/report_cross.pychecks in CI.
Changed¶
- The
power_mixeddocstring and the power guide say that a preregistered simplification rule, which moves to a simpler model whenever a fit is singular, is simulated only in the R package. There,power_mixed()andprecision_design()take a ladder of models and report the share of the replicates analysed with each.power_mixedhere analyses every replicate with its one model. power_curvegives each grid point its own replicates. Every point reused the specification's seed, so neighbouring points shared their random numbers, whilesolve_curvetreats them as independent. Over 1,000 emulated curves a setting, its nominal 95% intervals covered the true sample size in 88% of solves on a tight grid. Point j of a sweep over J sizes now takesreplicate_seeds(seed, J)[j], the rule of the R twin'ssweep_spec(), which brings that figure to 96.5%, so every numberpower_curveproduces changes.seeds="shared"keeps the old behaviour. The coverage figures quoted forsolve_curvecome from a new calibration over 1,000 curves a setting, where the earlier 35 in 36 rested on 12 curves whose points shared their seeds. Rerun with independent points, those 12 curves no longer favour the probit link over the logit, and the documentation now says so.- The home page gives the Zenodo concept DOI as a full link,
https://doi.org/10.5281/zenodo.21266313, in place of the Zenodo badge, and the About
page links every DOI the same way, so its reference line can be clicked. The BibTeX
entry's
url, on the page and indocs/pilotr.bib, gives the PyPI page, so a style that prints both fields no longer shows the DOI twice. powerrefuses designs whose rows are correlated, with the R twin's message. It t-tested every row as an independent observation and accepted any Gaussian design with a two-level between factor, including designs crossed with items, designs with a within factor and subjects nested in sites. Take 30 subjects crossed with 20 items, with by-subject and residual standard deviations of 1 and a by-item one of 0.3. With no true effect, the row-wise t-test was significant in 105 of 200 replicates, against 16 for a t-test of the subject means on the same data. It now takes one row per subject and no clustering, and otherwise raisesNotImplementedErrornaming the item unit, within factor or grouping factor it found.power_curverefuses the same designs.- The replicates of
power,power_curveandpower_mixedskip validation, since the specification is validated once before them. Each replicate used to validate it again. validate_specrefuses a specification with two or more factors between the same unit unless it declares specification version 0.4, which allocates such factors jointly. pilotr assigned the levels of each between factor to blocks of units on its own, so two such factors fell into the same or overlapping blocks. A 2 × 2 between-subjects design over 40 subjects produced cells of 20, 0, 0 and 20, and the second effect and the interaction could not be estimated. The refusal names the encoding that works in every version, one between factor whose levels are the cells, and reads the same in the R twin.- The crossed mixed-effects example on the power page states that every subject sees every item
in both conditions. It points a counterbalanced study, which has half the observations and
lower power, to the
counterbalancefield of specification version 0.4, whichpower_mixedanalyses. For earlier releases it points to the two-list encoding of the specification, whichpower_mixedanalyses as well. - The specification page states how units are allocated to levels and clusters, and gives worked encodings for a 2 × 2 between design, a two-list counterbalanced design and randomisation within clusters. It also says that simulated data are complete, so expected attrition is allowed for by recruiting N / (1 − p).
power_mixedfits only the random terms the specification declares, each as an independent variance component, and warns once per call when the specification correlates a group's random effects, naming the groups whose correlations the model leaves out. A design with random intercepts alone is no longer fitted with slope components as well, so its results change. On the 12 × 8 design of the power guide, which declares by-subject and by-item intercepts and slopes, every number is as before. Its documentation states what the model fits and what it leaves out, where the project README had put the two languages at capability parity.power_mixedrefuses a design without random effects withNotImplementedError, which namespowerfor the two-group design and the R twin'slm()andglm()fits for any other. It used to refuse every design without an item unit. A contrast without a coefficient is not in the model, as in the R twin, so a null effect is planned with a coefficient of 0, and a specification without coefficients is refused in the R twin's words. Up to 0.3.0,{}tested the contrast against a true effect of 0.n_returnedfrompower_mixedcounts every replicate that returned a fit, and each effect's power is taken over the fits that give that effect a finite estimate and p-value, as in the R twin. A fit that left the tested contrast without a p-value used to count as no fit.- For a
bernoulli,poisson,ordinalorbetaresponse,power_mixedwithholds the Type S of an interaction, as the R twin does. A bounded response can reverse the sign of a difference of differences between the response scale, where the linear model estimates it, and the link scale of its true value. n_convergedfrompower_mixedcounts the fits that converged without a warning and without a singular variance component, as in the R twin. It counted every fit that returned, which is nown_returned.n_attempted,n_singularandn_warningare new, and power is still taken over the returned fits. A fit is singular when a variance component's standard deviation is below 1e-4 of the residual one, lme4's test. statsmodels' notice that a fit may be on the boundary counts against no fit, since every fit on the log scale draws it. Its notice of a Hessian that is not positive definite counts only against a fit that is not singular, since a component at zero can draw it by itself. On the 12 × 8 design of the power guide, the five counts equal those of the R twin'spower_mixed()given the same model.- The
power_mixedbackend names its test, a Wald z. With few subjects or items, a Wald z rejects more readily than the R twin's Satterthwaite tests, so in small designs Python's power tends to exceed R's on the same data: 6 of 12 replicates against 4 of 12 on the 12 × 8 design of the power guide. With 8 subjects, 6 items, random intercepts alone and no true effect, it rejected in 0.070 of 300 replicates (Monte Carlo standard error 0.015), where the R twin's Satterthwaite test of the same model rejected in 0.057 on the same data.tools/calibration/wald_null_rate.pymeasures both. power_mixedfitsbernoulli,poisson,ordinalandbetaresponses with a linear model on the response scale. It now warns for them and withholds the mean estimate and Type M, returning both asnan, since they compared that scale with a true value on the link scale. A linear model of a 0/1 response estimates a difference in probability, which says nothing about how far a logit-scale effect is exaggerated. Type S, which compares signs alone, and power are kept. The warning reads as the R twin's, whosepower_mixed()now fitsbernoulliandpoissonresponses withlme4::glmer()on their link scale.- The power guide sets each response family against the model each
power_mixedfits, and against the R package'sprecision_design(). - NumPy integers and floats,
pathlibpaths and pandas data frames are accepted wherever a number, a path or a curve is, andvalidate_specaccepts a path, as in R. A NumPy integer was refused as not a whole number, sopower_curverefused a grid made withnp.arange, and a path object was refused as not a JSON object.workerstakes any whole number, as in R, andsolve_curvereads a NumPy value ofnas a replicate count, where it looked for a column of that name.power_curverecordsn_subjectas a Python int, so a curve swept over a NumPy grid can be written as JSON. - The cross-language guarantee is stated as it holds, in the README, the documentation site and
the specification. Python and R produce bit-identical data wherever they share a maths library,
as on Linux. On Windows they do not, and a value that passes through
exp(),log()orpow()can differ in its last bits. Measured there, about 1.4e-5 of normal draws differed, since the tail branch of AS 241 callslog(), so Gaussian data are not exempt. Lognormal and ex-Gaussian responses differed by one unit in the last place and unrounded Beta responses by up to 35, and a discrete outcome can in principle move by a whole category. The guarantee used to cover the Gaussian family everywhere and every family withroundset, althoughroundis refused for the discrete families. Rounding has removed every continuous difference observed, but it guarantees nothing. 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 callspow(). A Beta response whose Gamma shape is exactly 1/3 or 1/4 can therefore differ in its last bits on any platform. Within one language, the data are identical on one platform, and across operating systems on the same terms: on Windows, CPython'sexp()departed from the correctly rounded value for 0.48% of 700,000 arguments. - The specification documents every sampling rule a third implementation needs, in a section of
pseudocode: the partial Fisher-Yates shuffle, the block rules, uniform predictors and
reliability, the Cholesky factorisation, the two-branch inverse logit, each family's response,
the Poisson walk, the ordinal rule, the Marsaglia-Tsang Gamma sampler and the Beta ratio. It
corrects the Gamma sampler's draw consumption. A proposal draws a uniform only when its normal
gives a positive
v, and a boosted variate draws its uniform after the Gamma(shape + 1) variate. The schema refuses an unknown top-level field and a response parameter its family does not use, requires each family's parameters, and accepts the single-value forms ofvary_withinandthresholds, asvalidate_specdoes in strict mode. The parity harness checks that the schema accepts every specification of its battery that both twins accept. - The
replicate_seedsdocstring gives the right reason why consecutive seeds correlate. Seeding is affine in the seed, so the first draws of consecutive seeds step by a constant mod 1 and correlate 0.95, and a longer warm-up changes only the step (Matsumoto et al., 2007, https://doi.org/10.1145/1276927.1276928). It said that consecutive replicates began a few steps apart in one sequence. - The
solve_curvedocstring says that when an effect varies from item to item, power at a fixed number of items levels off below 1, however many subjects are added, as Westfall, Kenny and Judd (2014) and Judd, Westfall and Kenny (2017) show. A curve over subjects that levels off near the target can then be solved far from the true crossing, or as though it crossed when it never does. The docstring points to curves over the number of items, one at each of a few subject counts large enough to keep their own ceiling well above the target. The R twin's?solve_curvesays the same. - The contributing guide names the checks in
tools/parity/that CI runs on the cross-language contract. It namedexamples/parity_check.py, which compares CSV files to a tolerance of 1e-6. The guides of both no-code apps say thatpower_mixedfits with statsmodels. They said lme4. The theme toggle on the website's landing and not-found pages, which lead to this package's documentation, now works when the browser refuses the page any storage. - The docstrings of
load_specandsimulatesay that avalidateother thanTrueorFalse,Noneand0among them, validates leniently, as in the R twin.load_specnamed truthy values only, andsimulatesaid nothing of it.
Fixed¶
- The citation on the About page names the Python package version alone. It read "R and Python package version", although the two packages are released separately and their version numbers can differ. The BibTeX file the page offers for download says the same.
- The 0.3.0 entry carries the date the release reached PyPI, 2026-08-27.
load_specreads a specification as R's jsonlite does. A one-element array where a single value belongs,[]for an emptycoefficients,slopesorcorrelationsobject (both asjsonlite::write_json()writes them by default) and a JSONnullin an optional field are read as the value, the empty object and an absent field. Each previously raised or was refused.- A whole number written as a float, such as
"n": 64.0, no longer raisesTypeErrorinsimulate. The schema counts it as an integer and R simulates it. validate_specraisesValueErrorfor every malformed shape, as documented. A list-valued family raisedTypeErrorand a string-valuedresponseraisedAttributeError.validate_specrefuses"item": nullamong the units and arandomof0orfalse, in R's words. The first passed validation and then raisedTypeErrorinsimulate, and the second was read as no random effects.load_specreads files as UTF-8 and accepts a byte-order mark, as R does. It used the locale encoding, so on Windows the level "fácil" loaded as "fácil" and "Łatwy" raisedUnicodeDecodeError.load_specrefuses a repeated key within one object, andvalidate_speca seed beyond ±(2^53 − 1). Both used to give data that differed from R's.validate_specrefuses specifications whose names collide. Each of these validated and then moved an effect, rescaled a variance or overwrote a column without a word: two columns with one name (a factor calledsubject, a response named like a factor), a contrast column defined by two factors or named like a predictor, a unit, a grouping factor, another factor or the response, an interaction whose analysis columna_balready exists, a level listed twice, a correlation pairing a term with itself or giving one pair twice, a factor both between and within a unit, and arandom.itementry in a design without items, which was dropped. Where a column was overwritten,simulatewrote a second column under the same name while R replaced the first, so the twins exported different tables. Each refusal reads the same in the R twin.validate_specrefuses a factor, predictor or extra grouping-factor name that is empty or made only of spaces, and a response name made only of spaces (an empty one was already refused). Such a name validated and then became a column with no visible name.powerreads a missing coefficient for the tested contrast as a true effect of 0, with Type S and Type M undefined. It raisedKeyError, aspower_mixeddid, which now tests the coefficients the specification lists (see Added).power_mixedanalyses alognormalresponse on the log scale, as it already did forshifted_lognormaland as the R twin does. It fitted the raw values, so the mean estimate came out in the response's units. Take 12 subjects crossed with 8 items and a true effect of 0.1 on the log scale. Six replicates gave a mean estimate of 53.1 and a Type M of 553, where the log scale gives 0.136 and 1.36, as R does.power_mixedreturns no estimate for a replicate whose response has no logarithm, as the R twin does, andn_returnedleaves that replicate out. Such a response is ashifted_lognormalvalue rounded down to the shift, or now alognormalvalue rounded to 0. The shifted family raisedValueError: math domain error, which ended the whole call.power_mixedfits with Powell's method followed by L-BFGS. statsmodels' default chain of optimisers stopped short of the REML optimum in most crossed fits, which the documentation had described as statsmodels overstating random-slope variance. Over the first 12 replicates of the crossed reaction-time example, the standard error of the effect averaged 0.0278 against lme4's 0.0190, and 2 replicates were significant against lme4's 6. The standard errors now match those of lme4's uncorrelated model to four decimal places in 11 of the 12, and the same 6 replicates are significant. A replicate whose Powell fit raises is fitted again with the default chain and counts as a fit with a warning.- A rounded response is rounded by R's algorithm, so the twins agree at decimal ties. CPython's
round()rounds the exact binary value, while R takes whichever of the two decimal candidates is nearer in double precision, soround(1.05, 1)gave 1.1 in Python and 1 in R. The two disagreed on 166 of 5,000 values probed within a few units in the last place of a tie and on none of 1,000 random values. The shipped examples simulate the same data as before. Withvalidate=False, aroundthat is not a whole number, which validation refuses, counts asfloor(round + 0.5)decimal places, as in R. It raisedTypeError. - A Poisson mean above about 708 is refused. The sampler's starting value
exp(-mean)is subnormal there, and from a mean of about 738 the counts were biased or intermittently refused, while the specification promised service up to 746. Over 40,000 draws, a mean of 744 drew counts with a mean of 733.5 and a variance of 407, and a mean of 742 was refused for 4% of them. The refusal reads as the R twin's and asks for a mean of at most 708. - The Poisson sampler evaluates each term of its recurrence as
(p * lam) / k, as R does. The other bracketing,p * (lam / k), lands on a different double for most cumulative sums, and a uniform falling between the two gave a different count. At a mean of 3.7, a uniform of 0.4941532441504185 drew 4 here and 3 in R. In 300,000 draws from the generator, at means from 0.5 to 100, no count differed. Dataset.to_csvwrites UTF-8 and quotes a field that holds a comma, a double quote or a newline. It wrote the locale encoding, so on Windows a Polish label raisedUnicodeEncodeErrorand a Spanish one came out in bytes that no UTF-8 reader accepts. A comma in a label or a column name shifted every later field of its row. Lines still end in LF on every platform, and a data set without such characters is written byte for byte as before.- A NumPy float64 in a specification no longer reaches the simulated data as a NumPy scalar,
which
to_csvwrote asnp.float64(...)under NumPy 2. Every number in the specification becomes a Python int or float before anything is drawn, on a copy of the caller's. - A specification written by the R twin's
spec_json()carries exactly the numbers R held, soload_specreads the same doubles. The R writer checked its shorter forms with a reader that rounds twice on R builds with long doubles, and about one number in 5,000 to 7,000 was written one unit in the last place away, whichjsonthen read as written. The Python package is unchanged. A fixture written by R and a parity check over 20,000 numbers now hold the two packages to the same reading. - Beta responses stay inside (0, 1). Near a ceiling,
x / (x + y)rounds to exactly 1 in double precision (161 of 4,000 rows at an intercept of 3 andphi2), and rounding sent low proportions to exactly 0 (17% of the rows at three decimals in one realistic design). brms'sBeta()refuses either value. An exact 1 or 0 now becomes the nearest double inside the interval, a rounded value at 0 or 1 becomes the nearest value inside it at that many decimals, andvalidate_specrefusesround0 for Beta, as the R twin does. No other value changes, so the shipped examples simulate the same data as before. The sampler ispilotr.core.beta_response(rng, eta, phi), which replacesbeta_draw(rng, a, b), a function the documented API never included. - Extreme linear predictors are refused with the R twin's message.
simulateraised a bareOverflowErrorfor a lognormal or shifted lognormal response whoseexp(eta + sigma * z)overflowed, andZeroDivisionErrorfor a Beta response whose shape parameter was zero or whose two Gamma variates both underflowed. R returnedInf, 0, 1 orNaNin the same cases. - The install commands in the README and the getting-started guide carry no trailing comments,
so they paste into any shell. At an interactive prompt, zsh, the default shell on macOS, reads
#as the start of a comment only when told to, sopip install pilotr # core enginehanded the#and the words after it to pip, which stopped with "Invalid requirement: '#'". The blocks now hold the bare commands, and the prose before them says what each extra adds. The extras were already in double quotes, which keep zsh from reading the brackets as a filename pattern. A test fails on apip installline in a shell block that carries a#comment, and on an extra outside double quotes, in the README, the guides, the examples and the sources. In a checkout, it also reads the repository's README and both copies of the contributing guide. powerrefuses a design with fewer than two subjects at a level of the between factor, so fewer than 4 in all, in the R twin's words, andpower_curverefuses such a grid point. With one subject at each level,powerreturned a power of 0, since scipy answers a group without variance with a nan p-value and a warning, while the R twin stopped int.test(). With one subject in all, it raisedStatisticsError. The error is aValueError.
Deprecated¶
- A within factor whose
vary_withinomits a unit of the design now warns, in strict and lenient validation alike. pilotr has always crossed a within factor with every unit, so the list changed nothing. From spec version 0.4, such a list is refused. - The top-level keys of the result of
power_mixedthat held its one effect's quantities,powertotype_m_mcse, are kept for this release besideeffectsand will be removed in the next. They hold the entry of the first contrast of a single within factor, as before, and reading one raises aFutureWarningthat names its place ineffects.
0.3.0 - 2026-08-27¶
Two of the changes below alter numbers that earlier versions produced. Both are deliberate corrections, and neither can be made without moving the output, so install 0.2.1 to reproduce output from 0.2.1.
Added¶
validate_spec()checks a design specification against the portable schema and against the cross-field rules the schema cannot express, andload_spec()andsimulate()now call it by default. A strict draft-07 schema had shipped since 0.1 with no code path consulting it, and several ways of getting a specification wrong produced plausible data and no error at all. A mistyped coefficient key resolved to no column and so silently set that effect to zero, which generates exactly the data of a null design and reports success. A response parameter left over from another family was ignored. Both are now refused. Both functions take avalidateargument, so a replicate loop can validate once and then skip the check.SPEC_VERSION, and thespec_versionfield it negotiates. A specification with no such field is a 0.2 specification. One that uses a 0.3 feature has to declare 0.3, because a 0.2 implementation reads it differently and generates different data without complaint, and one declaring a version newer than this implementation understands is refused outright.replicate_seeds()is exported, so a hand-written loop or a cluster array task can seed its replicates by the same rule the power functions use.- The
exgaussianresponse family, the registered model family for reaction-time work, takingsigmaandbeta. It is written in brms's parameterisation, in whichmuis the mean, so a specification and the model fitted to it agree on what the intercept means. - A predictor can now vary by
"observation", drawing a fresh value for every row, for a quantity that varies trial by trial. - A predictor can be drawn from a uniform distribution through
distwithminandmax. A uniform costs the same single draw as a normal, so switching between them does not move the random stream. -
A predictor can carry a
reliability, which simulates imperfect measurement. The latent value drives the linear predictor and any random slope keyed on the predictor, while the contaminated observed value is what appears in the returned data, which is what an analyst would actually have measured. -
solve_curveandtarget_nsolve a simulated design curve for the value that meets a target. The package computed the whole curve and then handed back the last and most consequential step, the number that goes into a preregistration, at replicate counts where neighbouring points are not significantly different. The documentation drew a dashed line at 0.80 and left the reader to judge the crossing by eye.target_nnow takes the recordspower_curvealready returns and reports the sample size at which power reaches the target, with a confidence interval on it, rounded up to whole subjects.solve_curveis the general form and reads a curve on any axis through the same column names.
The fit is a binomial probit regression of the decision rate against the swept value,
weighted by the replicates behind each point and inverted by the delta method that R's
MASS::dose.p applies to a fitted glm. The probit follows from the design itself:
under the normal approximation to a two-group comparison the probit of power is linear
in the square root of the sample size. Checked against R's stats::power.t.test over 36
combinations of effect size, target power and grid shape, at 400 replicates a point, the
solved sample size sat within 2.9% of the analytic answer on average and 6.9% at worst,
against 3.4% and 8.9% for a logit fitted the same way.
tools/calibration/solve_curve_calibration.R runs that comparison and writes every
figure quoted for it to a file beside itself. Where the two-parameter model does not
describe the curve, the interval is widened by the heterogeneity factor of probit
analysis, reported as dispersion.
Nothing extrapolates. A curve that does not reach the target within the sizes it swept is
refused, with the range it did cover, and so is a fit that solves past the end of the
sweep, a curve with no trend to invert, and a slope that cannot be told from zero. Every
refusal message is character-for-character identical with the R twin's, checked by
tools/parity/solve_cross.py.
-
power_curvereports the replicate counts behind each point. Its records gainn_simsandn_significantalongsiden_subject,powerandtype_m. A power estimate over 1000 replicates should weigh more in a solve than one over 50, and the count is what says which it is. Without it a curve could not be handed tosolve_curveat all. -
The packaged example specifications are now tested against the repository's own copies.
spec/examples/*.jsonis canonical and both packages carry a mirror so an installed copy can reach it, but nothing enforced the mirror. The existing test could not: a stale packaged copy still loads and simulates perfectly well, it simply describes a different design from the one the repository documents. The new test compares the bytes, and is twinned withtest-examples.R. - CI now tests the built distribution as well as the checkout. The matrix gains
Python 3.14. A new job installs the built wheel into a bare environment outside the
repository and calls
pilotr_example()there, then resolves and parses one specification: that is the only check that can catch the example mirror going missing from the distribution, since an editable install finds the repository copy regardless. A second new job installs the declared minimum dependency versions of the extras. A weekly schedule runs the suite when nobody has pushed, since the workflow's paths filters mean a quiet month otherwise sees no run at all.
Changed¶
- The power functions no longer seed replicate
iwithseed + (i - 1). Consecutive seeds are not independent streams in this generator, and the measured consequence was severe: the first draw of replicateicorrelated 0.95 with the first draw of replicatei + 1. Seeds now come from the shared generator throughreplicate_seeds(), which brings that correlation to -0.02. Every numberpower(),power_curve()andpower_mixed()produce is therefore different from 0.2.1, and the first replicate no longer uses the specification's own seed. - A random slope keyed on an interaction, such as
"z_cosine:z_ISI", now reaches the data. It was accepted, sized into the covariance and drawn from the random stream, and then looked up as though it were a plain column, which returned nothing, so it was silently discarded. Any design that used one was anti-conservative and its data have changed. - The linear predictor is accumulated as an explicit left fold in the order set
out in
spec/SPEC.md. Floating-point addition is not associative, and R folded the terms one at a time while Python summed them and added the total, so the two disagreed on 63.8% of rows in a matched reproduction over 200,000 rows. - No built-in summation is used anywhere in the generative core. CPython's
sum()has applied Neumaier compensation to floats since 3.12 and base R'ssum()accumulates in 80-bit long double. Both are more accurate than a plain double fold, in different ways, so an inner product of three terms or more could land on different doubles in the two languages. Every inner product is now an explicit fold. - The cube in the Gamma sampler is written as two multiplications rather than
** 3. Python calls the librarypow()while R special-cases small integer exponents, and over 200,000 draws in the sampler's range the two disagreed on a third of inputs by up to 6 ulp. That value decides a rejection step, so it also changed how many draws were consumed, which accounted for every difference in thebeta_proportionexample. -
The random-effect covariance is bracketed as
(sd_i · sd_j) · r_ij, matching R. Multiplication is commutative but not associative in floating point, and the difference propagated through the Cholesky factor into every random effect drawn. -
The Bayesian design-analysis record carries the rule that produced it, namely the decision thresholds, the convergence gate, a fingerprint of the specification and the pilotr version, and the aggregator refuses to pool replicates that disagree on any of them. Two array runs with different ROPEs previously aggregated into one table with nothing to tell them apart.
- The parity harness anchors only the IEEE-754-exact cases. The golden anchor's
criterion was "zero ulp allowance", which admitted cases whose bytes pass through
exp(),log()orpow()whenever the platforms measured so far happened to agree on them.tolerance.jsonnow classifies those cases as transcendental explicitly: they stay gated by the cross-language comparison, at zero ulp unless an allowance is recorded, andgolden.jsonpins only the Gaussian cases. The validator cross-check (tools/parity/validate_cross.py) also runs in CI now, and its defaultRscriptis whichever one is onPATH, where it used to be a path on the author's machine. - The parity contract now has a third gate, at a relative tolerance where the other two
work in ulps.
solve_curveis an iterative fit, and it callsexpand the normal distribution function at every step, so a golden hash would pin one platform's maths library exactly astolerance.jsonsays such cases must not be. It is kept out ofgolden.jsonand out of the dump harness, and gated bytools/parity/solve_cross.py, which puts fixed curves through both engines and compares the solved value, its interval and the text of every refusal. The allowance is 1e-9 relative, against a largest measured disagreement of 5.0e-15.
Fixed¶
- A random-effect covariance that is not positive definite is now an error naming the grouping factor and the column at which the factorisation failed. The failing pivot was previously clamped at zero and the result returned in silence, which produced random effects whose standard deviations were several times those requested.
- A correlation naming a random-effect term that does not exist is reported against the entry the user wrote, where it used to fail as a lookup error deep inside the covariance code.
-
varies_byis validated against the three unit names that exist. Anything other than"subject"was previously read as item-level, so a predictor declared to vary by"trial"was silently given one value per item. -
A Poisson mean beyond the sampler's reach was returned as the iteration cap. The inverse-CDF walk starts from
exp(-mean), which underflows to exactly zero once the mean passes about 746 (a poisson intercept of 7 already implies a mean of exp(7), about 1097), after which every simulated count came back as 1000000 while reporting success. Both engines now refuse such a mean, namingexp(eta)and the offending value, with message text character-for-character identical across the twins. A linear predictor large enough to overflowmath.expitself folds into the same refusal, so it never reaches the caller as anOverflowError. Feasible means are untouched and the parity dumps are unchanged. - A true effect of exactly zero broke both engines, and the package recommends that
input.
design_conditions()deliberately produces a null condition so a run can show how often it declares something when there is nothing to find. Feeding it throughpower()raisedZeroDivisionErrorin Python and returnedInfin R, with Type S quietly degenerating to "the estimate is positive". Type S and Type M are now undefined when the true effect is zero or unknown, in both engines, which is the guard the mixed-effects path already carried, applied to the other three sites. - Four small twin divergences are closed. A non-whole seed now truncates identically in
both engines, a whole
spec_versionis read the same however it was written, a non-object unit is now reported as such, where R used to crash with a base error, andreplicate_seeds()is annotated as returning integers. Message text is character-for-character identical across the two engines in each case.
0.2.1 - 2026-07-23¶
Changed¶
- The guides load a bundled design specification through
pilotr_example(). The path relative to the repository that they used before resolved only inside a checkout, so a reader who had installed pilotr and copied the line met a missing-file error.
0.2.0 - 2026-07-15¶
Added¶
pilotr_example()lists the design specifications shipped with the package, one per design family, and returns the path to each forload_spec(). The eight JSON files now travel inside the wheel, so the Worked examples page and any installed copy can reach them. This matches the R twin'spilotr_example().
0.1.0 - 2026-07-10¶
The first public version of the Python package. It simulates experimental and behavioural data from a portable JSON design specification, producing output that is bit-identical to the R package of the same name given the same specification and seed.
Added¶
simulategenerates a dataset from a design specification supplied as a plain dictionary or a JSON file, withload_specreading a specification authored elsewhere, such as one downloaded from the no-code app. Aper_subjectvalue below 1 or above the number of items is rejected with a clearValueError, and the R package raises on the same inputs.- Response families for Gaussian outcomes, reaction times, accuracy, counts, ordinal responses and Beta-distributed proportions.
- Power and design analysis through
power,power_curveandpower_mixed, withscipyandstatsmodelssupplied as optional extras so the generative core stays dependency-free. A specification without an item unit is rejected bypower_mixedwith a clearValueErrorrather than yieldingnanpower. - A cross-language random-number contract that reproduces the R package's data to
full floating-point precision. Every Monte Carlo replicate seeds the shared RNG
as
seed + (replicate - 1), the same indexed-seed rule as the R package across all analyses. power,power_mixedandpower_curvetake aworkersargument that spreads the Monte Carlo replicates across local processes withconcurrent.futures.ProcessPoolExecutor. Because every replicate seeds the shared RNG from its own index, any worker count returns results identical to a serial run, andpower_curvestarts one process pool and reuses it across the whole sweep.- A documentation site whose guides execute their examples at build time, so the
tables and figures shown are real
pilotroutput.