Skip to content

Roadmap

The plan to 1.0.0 and the work done towards it, from the package's ROADMAP.md.

This document tracks where the package is going. It is updated alongside meaningful code changes. Milestones are ordered, not dated. The history of what changed and why lives in NEWS.md; the statistical evidence lives in inst/validation/.

Where things stand (0.17.0)

  • On CRAN. 0.11.2 was accepted in September 2026, after one review round on 0.11.1; 0.12.0, 0.13.0, 0.14.0 and 0.15.0 are GitHub releases. 0.16.0 was accepted on 2026-10-29, after its auto-check. From 0.16.0 releases go to CRAN again, about one to two months apart (decided 2026-10-28), so not every GitHub release goes. CRAN's additional check of 0.16.0 with Intel MKL as BLAS failed on tests that compared two computations of the same numbers bit for bit. 0.17.0, published on CRAN on 2026-11-01, fixes that check: on CRAN the tests compare such numbers to a tolerance of 1e-10, off CRAN still bit for bit, and cran-blas.yml checks the package under MKL, OpenBLAS, BLIS, R without long doubles and a noisy BLAS on the pull requests that change it (#172, under 0.11.x).
  • The validation matrix is complete for every exported perform*Test(). The sweep found six broken procedures, all corrected or withdrawn, and twenty-five of the twenty-six heteroscedasticity tests hold their nominal level (inst/validation/README.md). The tests added since have studies of their own: inst/validation/harrison-mccabe-size-power.R and inst/validation/glejser-skewed-errors.R.
  • Weighted fits were outside that matrix, and wrong. Every design in it fitted an unweighted model. On a weighted fit the tests read the raw residuals, whose variance differs across observations by assumption, and rejected a correctly weighted model every time. 0.12.0 corrects that and adds weighted designs to the matrix (inst/validation/weighted-fits-size.R). In 0.13.0 the bootstrap tests accept a weighted fit too, with designs of their own in the same study, and since 0.15.0 they estimate the weights of a fitWLS() fit again in each replicate rather than take them as known.
  • Reference equivalence is asserted to 1e-8 against lmtest, car and plm wherever a reference implementation exists. lmtest::bptest() is not a reference for weighted fits: it tests the raw residuals.
  • The public surface has been reviewed. 0.14.0 gave each of the 129 exports of 0.13.0 a verdict (the export inventory under 0.14.0): 104 are core API, 22 are deprecated and warn, and 3 move to a companion package that has no name yet. With bootstrapTestStatistic(), simulate_size() and simulate_power(), and runAdvancedDiagnostics() in 0.15.0, the package exports 133 objects, and test-public-api.R asserts the 107 planned for 1.0.0. 0.16.0 adds no export: it registers eight S3 methods, print() and summary() of the suites of runHeteroTests() and print() and coef() of their summaries. 0.17.0 changes no export, argument or default: NAMESPACE holds the same 133 exports.
  • The interface matches its description. In 0.15.0 every test takes (model, data, ...), performBoxMTest(data, group) being the documented exception, studentize chooses the studentized statistic throughout, ?test_results documents what every test returns, and every test refuses the same bad inputs with one message. The old call forms warn in 0.15.x and stop working in 1.0.0. The defaults changed with it: 999 replicates for every resampling function, White and Koenker as the default battery, and the corrected Glejser statistic.
  • The tests read the model they are given. In 0.16.0 the tests of a model, but the resampling tests, read the response, the design, the residuals, the weights, the offset and the rank from one validated model context per call, built from the stored fit, and match data to its observations, named data in any order and numbered data in the order of the fit (steps 1 to 7 of docs/design/model-context.md). White's degrees of freedom, Box's correction factor, Szroeter's null moments, Pesaran's test on an unbalanced panel and the ties of the Fligner-Killeen test are corrected, and the caches follow a contract. In 0.17.0 the other four steps are done: the resampling tests refit their replicates on the stored design (step 8), a fit with na.action = na.exclude is read as the same fit with na.omit (step 9), the runners and the functions outside the tests match data as the tests do (step 10), and a MASS::rlm() fit is refused and a penalised mgcv::gam() fit tested or refused by what each test assumes (step 11). What stands between 0.17.0 and 1.0.0 is the internals and the defect backlog (0.18.0), the evidence at the release gate and the open decisions (0.19.0), and the freeze.

What 1.0.0 means

1.0.0 is a promise rather than a feature count. From 1.0.0:

  • Exported names, argument names and order, and defaults change only in a major release, and only after a minor release in which the old form still works and warns.
  • Every exported test returns an htest object with a documented, uniform set of fields.
  • A correction to a statistic, its degrees of freedom or its p-value is a bug fix. It is never deferred for the sake of compatibility. It bumps the minor version and gets its own NEWS.md section, as 0.11.0 did.

Version numbers follow from that: a patch release changes no reported value; a minor release adds functionality or corrects a statistic; a major release breaks the API.

Everything below exists to make that promise safe to give. The sequencing rule this file has always followed still holds: statistical correctness first, then the API, and never both in one release. 0.12.0 reopened correctness for weighted fits and closed it again, and 0.13.0 corrected what was found after it, so the API is next.

Guiding principles

  • Statistical correctness first. Every test must reproduce an established reference (or a defensible derivation) and be covered by a test that checks behaviour (size and power), not just object structure.
  • A reference comparison is not a substitute for a size check. Four of the six faults the sweep found were in procedures with no reference to compare against, and the two that had one agreed with it.
  • Guards are simulated, not stored. A stored-value regression test would have frozen each of those faults rather than caught it.
  • One consistent interface. All diagnostics return base-R htest objects and follow the perform*Test(model, data, ...) convention. (Since 0.15.0 every exported test does, except performBoxMTest(data, group), which tests the data rather than residuals; see 0.15.0.)
  • Scale and robustness as first-class concerns: streaming implementations for large data, resampling and robust variants for small or non-normal samples.

Milestones

0.11.x: CRAN maintenance (now)

  • Watch the CRAN check results across all flavours and fix anything flagged within the deadline CRAN sets.
  • README: install from CRAN first (install.packages("heteroTests")), with GitHub as the development route; add a CRAN badge and use the canonical URL https://CRAN.R-project.org/package=heteroTests.
  • The README feature list is out of date. It still advertises an HC0–HC4 covariance test and Cameron–Trivedi, both removed in 0.8.0. Bring it in line with the exports, and extend test-documentation-consistency.R so a README or vignette that names a removed function fails. The guard went into test-public-api.R, beside the list of removed exports, and also matches the names prose gives the removed tests.
  • Documentation drift: the performStudentizedBPTest() help page describes performKoenkerTest() as "the absolute-residual variant", which predates the Breusch–Pagan/Koenker correction. Koenker is the studentized n R^2 form.
  • Check that the Zenodo record picked up 0.11.2, and cite the CRAN release in inst/CITATION and CITATION.cff. Zenodo archives 0.11.2 and 0.12.0; only the 0.12.0 record links to CRAN.
  • Close tracking issue #38 as On CRAN. It was closed on 2026-09-27.
  • Reinstate a release gate. R CMD check --as-cran can stay weekly rather than per merge, but it must pass in full on the tagged commit (vignettes and --run-donttest included) before any submission, alongside win-builder (R-devel). release.yml now runs --as-cran --run-donttest on the tarball it is about to tag and creates no tag if the check warns or errors; win-builder stays a manual step (cran-comments.md).
  • The gate failed. The weekly --as-cran job stopped with an error on 2026-09-28 and on 2026-10-05: the \donttest{} example of performWhiteTestBootstrap(parallel = TRUE) starts detectCores() - 1 processes, three on the runner, and --as-cran allows two. The example is skipped on Windows, so a local check there does not see it. Fixed in 0.13.0 by changing the example, which now runs the call only where it starts at most two processes; the "CRAN submission checks" job passed on the branch on 2026-10-11 and on main on 2026-10-12. The function still uses detectCores() - 1 workers by default; since 0.15.0 n_cores sets them, and the result no longer depends on how the draws are split.
  • CRAN's check of 0.16.0 with Intel MKL as BLAS failed on 2026-10-30 (https://www.stats.ox.ac.uk/pub/bdr/Rblas/MKL/heteroTests.out, R-devel r90786): [ FAIL 179 | WARN 17 | SKIP 96 | PASS 15260 ], where the eight regular flavours that had checked it were OK. Every failure was an expect_identical() between two computations of the same numbers: a cached result and an uncached one, a call with data and one without, an lm and the Gaussian glm of the same model, an old call form and the new one, the streaming runner and the exact one, a test and its registered name. They agree to the last bit on R's reference BLAS; under MKL they differed by up to 1.6e-12 relative (performVarianceFormTest(), 0.008329376120413 against 0.008329376120400), and MKL gave different last bits for the same call made twice. In one the last bits changed a discrete outcome: on every row of iris twice, which absolute residuals of Fligner-Killeen's test tie, 8.133 for the lm against 8.149 for the glm, a fragility of performFlignerKilleenTest() itself (under 0.18.0). Fixed in #172. The tests compare such numbers with expect_identical_numbers() (tests/testthat/helper-numbers.R), bit for bit off CRAN, as in CI and on the author's machine, and on CRAN, as testthat's on_cran() decides it, with expect_equal() to a tolerance of 1e-10: waldo's, the mean absolute difference of the elements that differ relative to their mean expected size, or absolute where that size is at most 1e-10. That is 64 times the largest difference under MKL. It is used at 235 sites in 30 files, found by running the suite as CRAN runs it with every expect_identical() of two non-integral numbers recorded, and by the noisy BLAS below. Six blocks whose ties, merged residuals or cache hits turn on the last bits run off CRAN only, the Fligner block among them: 27 of the expectations CRAN ran on main. On CRAN, the F of the variance form test on the cells of the matrix with missing rows and White's test on a design 1e6 from zero are compared to 1e-8 (blas_tolerance()), Harrison-McCabe's two-sided p-value with twice the one-sided one on the scale of the integral both come from, and the p-values of Box's M, about 1.8e-10, by their logs. cran-blas.yml runs R CMD check --as-cran with NOT_CRAN unset in the R-hub containers for MKL (CRAN's version, 2026.2), OpenBLAS, BLIS and R without long doubles, and with a BLAS that moves its results by up to one unit in the last place (tools/noisy-blas.c, on three seeds), on pushes to main, weekly, and on the pull requests that change the package or its tests. On main before the fix MKL passed, 2025.3 and 2026.2 alike: on the runners' AMD processors MKL runs its generic code, whose last bits do not depend on where the data sit in memory, where CRAN's machine runs its code for Intel processors. The first version of the noisy BLAS failed 1,923 expectations, at the sites of all 179 of CRAN's; OpenBLAS, BLIS and noLD passed. Since the review of #172 the noisy BLAS leaves alone the integral results no IEEE BLAS changes and what the reference BLAS leaves exact, and moves equal results of one call alike. A real BLAS can break such a tie, when a row falls to a peel or tail loop whose kernel rounds otherwise; the tests whose ties turn on it were reviewed by hand, and those of Szroeter's test run off CRAN only. With the fix every job passes, the noisy BLAS on each of its three seeds.

0.12.0: weighted fits and the shape of the variance (done)

This release was not on the roadmap. It took the 0.12.0 number because a corrected statistic bumps the minor version, and the milestones below moved up by one.

  • Weighted fits. On an lm fitted with weights, every residual-based test is computed from the Pearson residuals sqrt(w) * e. Goldfeld–Quandt and RESET refit with the weights. Unweighted fits return bit-identical values.
  • performVarianceFormTest(), a test whose null hypothesis is a variance function (exponential or power) rather than constant variance.
  • fitWLS() takes var_formula and form, and records the variance function it fitted.
  • Evidence. inst/validation/weighted-fits-size.R and inst/validation/variance-form-size-power.R, with simulated guards in test-weighted-fits.R and test-variance-form.R.

Found on the way and left for their own changes:

  • Resampling on weighted fits. performWildBootstrapTest(), performWhiteTestBootstrap(), rbootstrap_test_statistic() and performQuantileRegressionTest() refused a weighted fit, because they refitted without the weights. Resolved in 0.13.0 for the first three: they resample the Pearson residuals, return each draw to the scale of its observation and refit with the weights. That change leaves an unweighted fit as it was; the corrections 0.13.0 also makes to unweighted refits are listed under 0.13.0 below. In inst/validation/weighted-fits-size.R, at 2000 replications with B = 499, they reject a correctly weighted fit 0.045 to 0.054 of the time in two designs. performQuantileRegressionTest() still refuses one: on the weighted scale its slopes differ across quantiles under the null hypothesis, and a version for weighted fits needs its own construction. A lead: the quantile regressions of sqrt(w) y on sqrt(w) X with a column of ones added have equal slopes under the null hypothesis, which is the location-shift form the test assumes. It needs a derivation of the reference distribution and a size study before it can ship.
  • runSurveyHeteroTests() did not use the survey weights. It called stats::lm(formula, data = data, weights = weights), where weights was a local variable. lm() looks the name up in data and then in the environment of the formula, found stats::weights, failed, and the tryCatch() refitted without weights. Resolved in 0.13.0 by making the function say what it does, because using the weights would have been worse: sampling weights are not inverse error variances, and a test on a fit that uses them rejected a homoscedastic model 97% to 100% of the time when the weights varied with the regressor. runHeteroTests(formula, design) did exactly that through a svyglm() fit, and now takes the unweighted route too. Evidence: inst/validation/survey-designs-size.R. A design-based test is under Candidate tests.
  • .ht_fit_from_formula() had the same scoping fault and always failed with invalid type (closure) for variable '(weights)', so the per-group refits built from an lm, glm or parsnip fit could not run. Fixed in 0.13.0: each group is refitted by evaluating the model's own call on it, which gives it its own rows of the weights and keeps the family of a glm.
  • tidy() returned one row per degree of freedom. A result with two parameters, such as the F test of performVarianceFormTest() or the wild bootstrap with its df and B, was tidied into two rows, and the grouped and suite methods stacked them. Fixed in 0.13.0: one row per test, with the second parameter in a column of its own, parameter2.
  • autoplot() of a test suite did not show significant results. The bars were p-values on a linear axis from 0 to 1, so a test with p = 0.002 had no visible bar, and the chart was empty exactly when the tests rejected. The highlighting of p < 0.05 never applied either: the condition was written isTRUE(df$p.value < 0.05), which is FALSE for a suite of more than one test. The grouped method had the same condition and added a facet for the suggestions column. Fixed in 0.13.0: the bars are -log10(p) on an axis labelled with p-values, with a line at alpha and the p-value printed beside each bar.
  • An additive variance function, sigma^2 = a + z'b, was implemented for fitWLS() and the form test and withdrawn before release. Under its own null, at 5000 replications, the test rejected 6.1% of the time at the 5% level with 150 and with 400 observations, outside the release gate, and the fitted variances were not all positive in 17% of samples at 50 observations and 6% at 150. It needs an estimator that keeps the fitted variances positive before it can come back.
  • Tests of constant variance on a fitWLS() fit take the estimated weights as known. With the variance function right and Gaussian errors, Koenker rejects 8% of the time at the 5% level with 150 observations and 11% with 600, White 6% and 8%, and Harvey never. That is documented, and performVarianceFormTest() is the calibrated alternative, but a correction for the estimation step would let compareModelDiagnostics() report calibrated p-values for a weighted remedy.

    The bootstrap tests hold the weights fixed and share the error: the wild bootstrap rejects 0.086 with 150 observations and 0.105 with 600, where it rejects 0.054 and 0.048 with the true weights in the same design. A bootstrap that runs fitWLS() again on every replicate is such a correction. In the same designs it rejects 0.042 to 0.060 with 150 observations for the wild bootstrap, the White bootstrap and rbootstrap_test_statistic() with Koenker's statistic, and 0.051 with 600 for the wild bootstrap (inst/validation/weighted-fits-size.R). Resolved in 0.15.0 for the bootstrap tests, as proposed: the argument reestimate_weights of the three functions refits the variance function fitWLS() recorded on the model, with the same var_formula and form, in each replicate, by default on a fit that carries that record; weights the user supplied are known and stay fixed (0.15.0, below). The tests of constant variance themselves still take the weights as known.

0.13.0: what was found after 0.12.0 (done)

This release was not on the roadmap either. It took the 0.13.0 number because it corrects statistics and adds functionality, and the milestones below moved up by one again.

  • Values that changed. The bootstrap tests keep an offset given as an argument when they refit an unweighted model, rbootstrap_test_statistic() matches the rows of the data to the fit by name and leaves out rows the fit dropped, and performWhiteTestBootstrap() regenerates a transformed response, where its p-value was 1. fitWLS() keeps the offset of the model it refits, and performVarianceFormTest() changes with it on a model with an offset. The Szroeter test that runHeteroTests() runs by name orders the observations by the fitted values instead of by the second column of the data. runHeteroTests() and runDiagnostics() no longer test a survey design through a fit weighted by its sampling weights. tidy() returns one row per test.
  • What was found on the way to 0.12.0: the resampling tests accept a weighted fit, and the survey designs, the grouped refits and autoplot() are fixed (above).
  • Two tests from the candidates: performHarrisonMcCabeTest() and performGlejserTest(robust = TRUE). Two more were measured and not adopted, the modified Wald test and stage 1 of the Dette, Neumeyer and Van Keilegom test (Candidate tests).
  • listDiagnostics(), and the release gate in release.yml (0.11.x, above).

0.14.0: review the public surface, deprecate and remove nothing (done)

Goal: every one of the 129 exports gets a verdict, which is one of core API, renamed, internal or moved out. Nothing is removed in this release. Everything that will go warns.

The author accepted the four proposals for this release on 2026-10-14: the dashboard and the benchmark suite move to a companion package and the recommendation engine stays, with its return format frozen; performCookWeisbergTest() stays, with an optional var_formula, and performNCVTest() is deprecated; test() is folded into summary.HeteroDiagnostic(); and simulate_*() and sigma_*() stay as a documented snake_case family while the one-offs are renamed. The companion package has no name yet, so the dashboard and benchmark functions are not deprecated until it has one.

  • Duplicate exports. Each duplicate below is deprecated in favour of the name it duplicates. performStudentizedBPTest() agrees with performKoenkerTest() to 1e-8 except on a model with an offset, which it drops (listed under 0.16.0). It keeps its implementation, so no value changed in 0.14.0; on a fitWLS() fit it read stale weights until 0.15.0 fixed that (listed under 0.16.0, done in 0.15.0).
    • performBreuschPaganTest() is performBPTest(), assigned at R/performBPTest.R:232.
    • performStudentizedBPTest() is a separate implementation that matches performKoenkerTest() in every column of the full sweep. Confirm the statistics agree to 1e-8, then keep one.
    • performCookWeisbergTest() delegates to performNCVTest() with the fitted values as the variance regressor. performCookWeisbergTest() becomes the one entry point for the Cook–Weisberg score test, with var_formula as an optional argument, and performNCVTest() is deprecated (decided above).
    • *Enhanced pairs: checkModel()/checkModelEnhanced(), plotResidualsFitted()/plotResidualsFittedEnhanced() and plotDiagnosticSuite()/plotDiagnosticSuiteEnhanced(). Merge each improvement into the base name.
  • Internal machinery that is exported: std_error(), std_warning(), validateTestInputs(), checkData(), TestFactory/test_factory(), the cache functions (cachedTest(), clearTestCache(), clearAnalysisCache()) and the ht_* logging functions. Unexport them unless a user-facing reason exists; for logging, keep at most ht_set_log_level(). Deprecated in 0.14.0 and unexported in 1.0.0, except the cache functions, which stay for now (decided), and ht_set_log_level().
  • The five r-prefixed exports (rbootstrap_test_statistic(), rcalculateEffectSize(), restimate_test_power(), rrunAdvancedDiagnostics() and rvalidate_against_reference()) follow no naming scheme in the package. Rename each to the house convention or unexport it. Four are done in 0.14.0: rbootstrap_test_statistic() is renamed bootstrapTestStatistic() and the other three are deprecated as internal helpers. rrunAdvancedDiagnostics() stays core API (decided), and was renamed runAdvancedDiagnostics() in 0.15.0.
  • test() is a generic with a very common name. Attached after devtools, it masks devtools::test(). Fold it into summary.HeteroDiagnostic() (decided above). Deprecated in 0.14.0, naming runDiagnostics(x$model, x$data, tests); summary.HeteroDiagnostic() calls runDiagnostics() and returns what it returned.
  • Naming convention. camelCase dominates (performXTest(), fitWLS(), plotX()). The simulation family (simulate_hetero(), sigma_*()) is declared a deliberate snake_case family and documented as one (decided above). The one-offs (run_benchmark_suite(), generate_benchmark_report(), simulate_type_I_errors(), simulate_power_analysis()) are renamed. The last two are simulate_size() and simulate_power() in 0.14.0; the first two move out with the benchmark suite instead.
  • Scope. The dashboard (R/dashboard.R, 698 lines) and the benchmark suite (R/benchmarking_system.R, 798 lines) move to a companion package (decided above), once it has a name. Between them they account for most of the optional Suggests (shiny, DT, plotly, htmlwidgets, bench). Open: the package has no name, so they are not deprecated in 0.14.0 (see Decisions needed).
  • Related model checks (performRESETTest(), performVIFDiagnostic(), performInfluenceDiagnostics(), performScatterDiagnostic()) are not heteroscedasticity tests. Keep them, grouped in the reference index as model checks, so they are not mistaken for tests of the variance.
  • Mechanism. Use base .Deprecated(), so no new dependency is needed. Each call warns and names its replacement. NEWS.md carries an old → new mapping table.
  • Exit criterion. An export inventory with a verdict for every one of the 129 exports, and test-public-api.R extended to assert the planned 1.0 export list, so that an accidental new export fails CI.

Export inventory

Every export in NAMESPACE on 2026-10-14, with a verdict. Of the 129, 104 are core API, 10 are renamed, 12 become internal and 3 move out. The S3 methods registered in NAMESPACE are not exports and are not listed; S3method(test, HeteroDiagnostic) goes with test(). The verdicts apply the decisions of 2026-10-14 as written, and the author's decisions on this inventory, taken the same day, are marked "decided".

For each renamed or internal export, the last column lists every caller found by a search of R/ (code, not comments or the definition), vignettes/ and inst/validation/, adds README.md where it names the export, and counts the lines in tests/ that call it. The notebooks in inst/tutorials/ call none of these exports. The article was not searched: it moves to the 1.0 API after 1.0.0. An export that becomes internal keeps working for its callers in R/, and for the tests, which run inside the namespace; a renamed export keeps working under its old name and warns. What has to change in each pull request are the callers outside R/, and the package's own calls of a name that will warn.

Export Area Verdict Evidence that the change is safe
performWhiteTest() test core API
performWhiteTestStreaming() test core API
performWhiteTestRobust() test core API
performWhiteTestBootstrap() test core API
performBPTest() test core API
performBPTestStreaming() test core API
performBPTestRobust() test core API
performBreuschPaganTest() test renamed: performBPTest(), the same object R/: none (an alias, assigned in performBPTest.R). Vignettes: using_heteroTests.Rmd:128. inst/validation/: full-sweep-size-power.R:169; 2 lines of README.md. Tests: 3 lines.
performKoenkerTest() test core API
performKoenkerTestStreaming() test core API
performStudentizedBPTest() test renamed: performKoenkerTest(), once item 2 shows they agree to 1e-8 (provisional, decided) R/: registry name "student_bp" (diagnosticsRegistry.R:70, test_factory.R:105); performBPTestRobust(studentized = TRUE) (robust_implementations.R:249); rvalidate_against_reference() (:455). Vignettes: none. inst/validation/: full-sweep-size-power.R:171, weighted-fits-size.R:138; 4 lines of README.md. Tests: 8 lines.
performGQTest() test core API
performParkTest() test core API
performSzroeterTest() test core API
performHarrisonMcCabeTest() test core API
performWildBootstrapTest() test core API
performQuantileRegressionTest() test core API
performRankPermutationTest() test core API
performHighDimensionalTest() test core API
performSpatialHeteroTest() test core API
performSpearmanTest() test core API
performLeveneTest() test core API
performGlejserTest() test core API
performBrownForsytheTest() test core API
performFlignerKilleenTest() test core API
performBartlettTest() test core API
performHarveyTest() test core API
performArchLMTest() test core API
performMcLeodLiTest() test core API
performHartleyFmaxTest() test core API
performOBrienTest() test core API
performCookWeisbergTest() test core API
performNCVTest() test renamed: performCookWeisbergTest(), which gains an optional var_formula (decided) R/: registry name "ncv" (diagnosticsRegistry.R:67); performCookWeisbergTest() delegates to it (performCookWeisbergTest.R:69); the benchmark suite (benchmarking_system.R:368). Vignettes: none. inst/validation/: full-sweep-size-power.R:165, pass-a-size-power.R:87, weighted-fits-size.R:133,196; 6 lines of README.md. Also README.md:34. Tests: 20 lines.
performSpreadLevelTest() test core API
performPesaranTest() test core API
performBPRandomEffectsTest() test core API
performDavidianCarrollTest() test core API
performVarianceFormTest() test core API
performBoxMTest() test core API
rbootstrap_test_statistic() test renamed: bootstrapTestStatistic() (decided) R/: performWhiteTestRobust() and performBPTestRobust() (robust_implementations.R:135,270). Vignettes: none. inst/validation/: offset-refits-size.R, weighted-fits-size.R, labels in make-table.R; 22 lines of README.md. Also the Decisions needed row on fitWLS() fits. Tests: 60 lines.
performScatterDiagnostic() model check core API
performVIFDiagnostic() model check core API
performRESETTest() model check core API
performInfluenceDiagnostics() model check core API
checkModel() model check core API
checkModelEnhanced() model check renamed: merged into checkModel() R/: performWhiteTestStreaming() (performance.R:267). Vignettes: none. inst/validation/: none. Tests: 8 lines.
runHeteroTests() runner core API
runHeteroTestsParallel() runner core API (decided)
runDiagnostics() runner core API
runDiagnosticPlots() runner core API
registerDiagnostic() runner core API
listDiagnostics() runner core API
registerPlot() runner core API
runMultivariateTests() runner core API
runTimeSeriesTests() runner core API
runPanelTests() runner core API
runSurveyHeteroTests() runner core API
compareModelDiagnostics() runner core API
compareTestResults() runner core API (decided)
analyzeMLResiduals() runner core API
HeteroDiagnostic() runner core API
test() runner renamed: its warning names runDiagnostics(x$model, x$data, tests); summary() is unchanged (decided) R/: summary.HeteroDiagnostic() calls it (HeteroDiagnostic.R:59). Vignettes: real_world_case_studies.Rmd:110, tutorial.Rmd:32, using_heteroTests.Rmd:100,105. inst/validation/: none. Also README.md:143. Tests: 19 lines. summary.HeteroDiagnostic() will call runDiagnostics() directly.
fitWLS() remediation core API
fitRobust() remediation core API
autoTransform() remediation core API
suggestRemediation() remediation core API
autoCompareRemediations() remediation core API
analyseDatasetCharacteristics() recommendation core API
suggestDiagnosticsForProfile() recommendation core API
interpretHeteroTestResults() recommendation core API
recommendRemediationStrategies() recommendation core API
warnDiagnosticAssumptions() recommendation core API
buildDiagnosticDecisionTree() recommendation core API
composeDiagnosticNarrative() recommendation core API
generateHeteroRecommendations() recommendation core API
simulate_hetero() simulation core API
simulate_arch1() simulation core API
sigma_linear() simulation core API
sigma_exponential() simulation core API
sigma_group() simulation core API
sigma_piecewise() simulation core API
sigma_poly() simulation core API
sigma_sin() simulation core API
sigma_multiplicative() simulation core API
sigma_spatial() simulation core API
sigma_logistic() simulation core API
sigma_inverse() simulation core API
sigma_power() simulation core API
sigma_step() simulation core API
sigma_u_shape() simulation core API
sigma_exp_decay() simulation core API
sigma_gaussian_peak() simulation core API
sigma_piecewise_linear() simulation core API
simulate_type_I_errors() simulation renamed: simulate_size() (decided) R/: none. Vignettes: none. inst/validation/: none. Tests: 3 lines.
simulate_power_analysis() simulation renamed: simulate_power() (decided) R/: none. Vignettes: named in the prose of troubleshooting.Rmd:44. inst/validation/: none. Tests: 3 lines.
plotResidualsFitted() plotting core API
plotSpreadLevel() plotting core API
plotResidualDensity() plotting core API
plotResidualQQ() plotting core API
plotBubbleVariance() plotting core API
plotDiagnosticSuite() plotting core API
plotBeforeAfter() plotting core API
plotResidualsFittedEnhanced() plotting renamed: merged into plotResidualsFitted() R/: plotDiagnosticSuiteEnhanced() (plotDiagnostics.R:279). Vignettes: none. inst/validation/: none. Tests: 5 lines.
plotDiagnosticSuiteEnhanced() plotting renamed: merged into plotDiagnosticSuite() R/: generateDiagnosticReport() (reporting.R:53); the dashboard (dashboard.R:536). Vignettes: none. inst/validation/: none. Tests: 4 lines.
theme_hetero() plotting core API
scale_colour_hetero_diagnostic() plotting core API
scale_fill_hetero_diagnostic() plotting core API
generateDiagnosticReport() reporting core API
downloadTeachingData() reporting core API
ht_set_log_level() infrastructure core API
std_error() infrastructure internal R/: 92 lines in 24 files, all internal callers, which keep working. Vignettes: none. inst/validation/: none. Tests: 9 lines.
std_warning() infrastructure internal R/: performance.R:181, performWhiteTest.R:232, validation.R:675. Vignettes: none. inst/validation/: none. Tests: none.
validateTestInputs() infrastructure internal R/: generateHeteroRecommendations() (intelligent_recommendations.R:686), the test factory (test_factory.R:80). Vignettes: none. inst/validation/: none. Tests: 21 lines.
checkData() infrastructure internal R/: 16 lines in 7 files (advancedDiagnostics.R, advancedRemediation.R, fitWLS.R, HeteroDiagnostic.R, intelligent_recommendations.R, reporting.R, runPanelTests.R). Vignettes: none. inst/validation/: none. Tests: 9 lines.
TestFactory infrastructure internal R/: only test_factory.R:98, which builds test_factory. Vignettes: none. inst/validation/: none. Tests: 2 lines.
test_factory infrastructure internal R/: through its alias .test_factory, in .run_registered_diagnostic() (performance.R:610-612) and the dashboard (dashboard.R:53). Vignettes: none. inst/validation/: none. Tests: 10 lines.
cachedTest() infrastructure core API for now (decided); revisited at 1.0.0, once 0.16.0 bounds the caches
clearTestCache() infrastructure core API for now (decided); revisited at 1.0.0, once 0.16.0 bounds the caches
clearAnalysisCache() infrastructure core API for now (decided); revisited at 1.0.0, once 0.16.0 bounds the caches
ht_enable_log_capture() infrastructure internal R/: none. Vignettes: none. inst/validation/: none. Tests: none.
ht_log_history() infrastructure internal R/: none. Vignettes: none. inst/validation/: none. Tests: none.
ht_clear_log_history() infrastructure internal R/: none. Vignettes: none. inst/validation/: none. Tests: none.
rcalculateEffectSize() infrastructure internal R/: performWhiteTestRobust(), performBPTestRobust() (robust_implementations.R:158,292), restimate_test_power() (bootstrap_methods.R:599). Vignettes: none. inst/validation/: none. Tests: 4 lines.
restimate_test_power() infrastructure internal R/: performWhiteTestRobust(), performBPTestRobust() (robust_implementations.R:159,293). Vignettes: none. inst/validation/: none. Tests: 3 lines.
rrunAdvancedDiagnostics() runner core API (decided); its name is still open, see the item on the r-prefixed exports
rvalidate_against_reference() infrastructure internal R/: none. Vignettes: none. inst/validation/: none. Tests: 2 lines. The reference comparisons live in tests/testthat/.
launchDiagnosticDashboard() dashboard and benchmarks moved out (companion package not named yet; not deprecated in 0.14.0) R/: none outside dashboard.R. Vignettes: none. inst/validation/: none.
run_benchmark_suite() dashboard and benchmarks moved out (companion package not named yet; not deprecated in 0.14.0) R/: none outside benchmarking_system.R. Vignettes: none. inst/validation/: none.
generate_benchmark_report() dashboard and benchmarks moved out (companion package not named yet; not deprecated in 0.14.0) R/: none outside benchmarking_system.R. Vignettes: none. inst/validation/: none.

Notes on the verdicts:

  • performStudentizedBPTest() is provisional. Item 2 first asserts that it agrees with performKoenkerTest() to 1e-8 over the designs of the full sweep, weighted, rank-deficient and fitWLS() fits included. Disagreement anywhere is a finding, not a rename, and is reported before anything else.
  • test() warns and names runDiagnostics(x$model, x$data, tests), which returns what test() returns. summary.HeteroDiagnostic() keeps its return value, so no value changes in 0.14.0.
  • Registry names. "student_bp" and "ncv" stay registered and keep calling what they call now, so their output does not change. Each warns once per call of runHeteroTests() or runDiagnostics(), not once per group or bootstrap replicate, and goes in 1.0.0 with the function behind it.
  • The cache functions (cachedTest(), clearTestCache(), clearAnalysisCache()) stay exported and are not deprecated in 0.14.0. They are revisited at 1.0.0, once 0.16.0 bounds the caches.
  • rrunAdvancedDiagnostics(), runHeteroTestsParallel() and compareTestResults() stay core API. The name of rrunAdvancedDiagnostics() is still open under the item on the r-prefixed exports.
  • The simulation renames. simulate_size() and simulate_power() join the documented snake_case family, since both simulate under the sigma_*() functions.
  • bootstrapTestStatistic() takes a test function as its first argument, so it is not a perform*Test(model, data, ...); the new name says what it does in the house camelCase.
  • The dashboard and the benchmark suite are not deprecated in 0.14.0, because the companion package has no name yet. They call plotDiagnosticSuiteEnhanced(), the test factory and performNCVTest(), which change before the code moves.
  • analyzeMLResiduals() and analyseDatasetCharacteristics() spell the verb two ways. Both are core API; the roadmap names no change for them.

0.15.0: make the interface match its description (done)

These changes break existing calls. They have to happen before 1.0.0, not after it.

  • The (model, data, ...) convention. Nine exported tests did not follow it:

    • no data argument: performArchLMTest(), performMcLeodLiTest(), performCookWeisbergTest(), performNCVTest(), performSpearmanTest(), performSpreadLevelTest(), performDavidianCarrollTest() and performHarveyTest();
    • no model argument: performBoxMTest(data, group).

    Decided on 2026-10-15 as proposed: the eight take data = NULL in second position, recovered from the model frame when omitted, and performBoxMTest() is the documented exception, since it tests the data rather than residuals. A call that passes the old second argument by position (performArchLMTest(m, 3), performCookWeisbergTest(m, ~x), performHarveyTest(m, "fitted")) is recognised by its type, works in 0.15.x, warns with the new call and stops working in 1.0.0; NEWS.md has the table. No value changes: every old and new form returns what the old form returned in 0.14.0, checked with identical() against 0.14.0. Extended on 2026-10-15 to every other test: performRESETTest(model, power) as the eight, and the 29 tests that required data now default it to NULL, the model frame. A call that worked returns what it returned, except performRESETTest(m, NULL) and performRESETTest(m, <data frame>), which read the second argument as power and returned NaN or a meaningless F. - [x] Argument names. Harvey used studentize and performBPTestRobust() studentized. Standardised on studentize, which matches lmtest::bptest(); studentized is accepted with a deprecation warning in 0.15.x and stops working in 1.0.0, and the robust_details$studentized field of the result keeps its name. No value changes. - [x] The name of rrunAdvancedDiagnostics(). Renamed runAdvancedDiagnostics() (decided 2026-10-15), the house convention for a runner. The old name warns with .Deprecated() in 0.15.x and goes in 1.0.0 with the names deprecated in 0.14.0. No value changes. - [x] Resampling defaults. B was 499 for the wild bootstrap, 999 for rank permutation, and 1000 for the White bootstrap and bootstrapTestStatistic(). Under the (1 + #) / (B + 1) convention, a test at level alpha is exact only when alpha * (B + 1) is an integer, and 1000 fails that at 5%. Done (decided 2026-10-15: B = 999): every resampling function draws 999 by default, the robust tests with bootstrap = TRUE and runAdvancedDiagnostics() (500 before) and performSpatialHeteroTest() (499 permutations before) included. Default p-values change; NEWS.md has an example for each. Of the recorded evidence only the spatial row of the full sweep used a default, and it was regenerated: size 0.045 to 0.050, power 0.362 to 0.350. - [x] Wild bootstrap multiplier (Rademacher or Mammen). Settle it by a size study at small n with skewed errors, and record the result in inst/validation/.

    Measured on 2026-10-15 in inst/validation/wild-multiplier-size.R: the cross-sectional design of Pass A at 30, 50 and 100 observations, with Gaussian, t5 and centred exponential errors, 5000 replications and B = 999, both multipliers on the same samples. The decision rule was written into the script before the full run started; the script was first committed with its results. Neither multiplier holds the level in more cells, and no paired difference is outside its 99% interval (within Monte Carlo error). Both are inside the release gate in the same six of the nine size cells, those with Gaussian and t5 errors, and outside it in the same three, those with exponential errors, where Rademacher's multiplier rejects 0.082, 0.073 and 0.061 of the time and Mammen's 0.078, 0.070 and 0.059: Mammen's rate is lower in all three cells, each difference inside its 99% interval. The interval around 0.049, the rate of an exact test with B = 999, gives the same split. Against sigma^2 = exp(0.4 x1) the power of the two differs by less than one point at each n. Done (decided 2026-10-16: keep "rademacher", as the study recommended); no value changes. The help page said the bootstrap controls the size for heavy-tailed errors; that holds for the t5 errors of the study and not for the skewed ones, with either multiplier, and #125 corrected the page. A wild bootstrap without a multiplier, which may hold the level better under skewed errors, is to be measured in 0.16.0 (below). - [x] Reproducibility under parallel = TRUE. A search of R/ finds no clusterSetRNGStream() and no L'Ecuyer set-up on the mclapply() and parLapply() paths, so set.seed() does not make parallel resampling results reproducible. Done: replicate i draws from stream i of a L'Ecuyer-CMRG generator seeded by one draw from the session's, on both paths, in bootstrapTestStatistic(), performWhiteTestBootstrap() and runHeteroTestsParallel(), so the value does not depend on the number of workers. Serial calls are unchanged; parallel and serial values differ and are each valid (test-parallel-reproducibility.R). runHeteroTestsParallel() with one worker runs in the session on the same streams, so its value does not depend on n_cores either, and changes. The White bootstrap runs on a socket cluster on Windows, where it stopped, and gains n_cores. The forked path is exercised by CI on Linux and macOS only. - [x] Bootstrap tests on a fitWLS() fit. Accepted for 0.15.0 as proposed under Decisions needed, and moved from 0.14.0 because it changes values. performWildBootstrapTest(), performWhiteTestBootstrap(), bootstrapTestStatistic() and its deprecated name take reestimate_weights. NULL, the default, runs fitWLS() again on each replicate of a fit that carries its record of the variance function and holds the weights of any other model fixed; FALSE holds them fixed, as 0.13.0 and 0.14.0 did; TRUE on a model without the record is an error. Each replicate is fitted as the model fitWLS() was given, the recorded variance function is fitted again on the recorded regressors, and the replicate is refitted with the weights that gives; one whose variance function cannot be fitted counts as a failed replicate. The default p-values on a fitWLS() fit change, announced in bold in NEWS.md. An lm, a weighted lm and a Gaussian glm return identical() values, and so does a fitWLS() fit with FALSE. In inst/validation/weighted-fits-size.R, at 2000 replications with B = 499, the wild bootstrap rejects 0.0605 and 0.0515 of the time at 150 and 600 observations, the White bootstrap 0.0415 and bootstrapTestStatistic() with Koenker's statistic 0.045, against 0.0865, 0.105, 0.064 and 0.0825 with the weights held fixed. Those four rows were measured by the prototype of 0.12.0 and not run again: the package reproduces it on 200 samples of each, p-values, replicates and random draws (inst/validation/weighted-fits-refit-reproduction.csv). The wild bootstrap at 150 observations is outside the release gate's interval [0.042, 0.058] and the White bootstrap just below it; a study at 5000 replications is listed under 0.19.0. - [x] The default battery. runHeteroTests(), test()/summary() on HeteroDiagnostic, the advanced diagnostics and the dashboard all defaulted to c("white", "breusch_pagan"), and "breusch_pagan" is the classical statistic. The package's own sweep shows it rejecting 28.75% of the time under a t5 null, against 4.75% for Koenker. Done (decided 2026-10-15: White and Koenker): the default is c("white", "koenker") in those functions and in runDiagnostics(), compareModelDiagnostics(), compareTestResults() and runSurveyHeteroTests(), and the classical test runs by name. suggestRemediation() reads "koenker" as well as "breusch_pagan", and suggestDiagnosticsForProfile() recommends Koenker's test as the baseline. runAdvancedDiagnostics() already computed the studentized statistic by default and is unchanged. No recorded number moves. - [x] The default of robust in performGlejserTest(). It was FALSE, Glejser's (1969) statistic, which in inst/validation/glejser-skewed-errors.R rejects a true null hypothesis 8.8% to 17.7% of the time at the 5% level when the errors are skewed, at every sample size from 50 to 1000. Done (decided for 0.15.0 as proposed: TRUE): the default is the corrected statistic of Im (2000) and Machado and Santos Silva (2000), added in 0.13.0. From 150 observations it holds its level under every error distribution measured, inside the release gate in all 27 of its null cells; at 50 observations it rejects 5.7% to 6.6% of the time under skewed errors, four of those six cells above the gate, and gives up 1.1 and 1.9 points of power under Gaussian errors. Default values change; NEWS.md has an example. robust = FALSE returns what the default returned in 0.14.0, checked with identical() against 0.14.0. Of the recorded evidence the full sweep and the weighted fits call the default, and their two rows were regenerated from the same seeds: size 0.0600 to 0.0575 and 0.0475 to 0.0450. Pass A and the Glejser study name Glejser's statistic and now give robust = FALSE; their numbers do not move. - [x] Return contract. Document the fields every perform*Test() returns, both the htest core and the package's additions, on one help page. Check every exported test against it in a single parametrised test. This also retires the hand-maintained printer and metadata drift listed under the old technical debt. Done: ?test_results and test-return-contract.R. Five tests gained the name df of their parameter and performBoxMTest() its data.name; no number changed. - [x] Uniform input validation across all exported tests. The README lists its absence as a known limitation. test-input-validation.R gives every test a glm that is not a least-squares fit, a model with an NA coefficient, data that lack rows of the fit and data that lack a variable, and every test stops on each with the same message. Refusing the inputs some tests returned a value for was decided on 2026-10-15: 36 test names refuse a glm of a family other than gaussian or a link other than the identity, five refuse an NA coefficient, and the variance-form, Pesaran and random-effects tests refuse data that cannot be matched to the fit (NEWS.md lists the calls). A Gaussian glm with the identity link is accepted and gives the result of the lm. With prior weights the GQ, RESET, White bootstrap and wild bootstrap tests, which read the weights of an lm, read them since then, decided on 2026-10-15, where they took the glm for an unweighted fit, and the quantile regression test refuses it as it refuses a weighted lm; those calls change value. No recorded number moves.

0.16.0: the model context, part 1 (increments 1–7), and statistics fixes (done)

Planned as one milestone for the statistics evidence, the dependencies and the internals, with no API change. The author split it on 2026-10-26: 0.16.0 is the first seven steps of the model context and the statistics fixes found on the way, and the rest of the plan moves to 0.17.0 (the model context, part 2), 0.18.0 (internals, dependencies and the defect backlog) and 0.19.0 (release-grade evidence and the open decisions). No export or argument changes; the default of chunk_threshold_mb in runHeteroTests() and runDiagnostics() becomes Inf, and the print and summary methods of the suites, approved on 2026-10-21, are added.

  • performBoxMTest() is conservative, with size 0.012. It uses the chi-square approximation. Compare it against Box's F approximation, then either switch or document the conservatism on the help page. Done in 0.16.0 (decided 2026-10-25: fix the factor, keep the chi-square). The comparison, inst/validation/box-m-approximations-size.R, found that the 0.012 is not the approximation's: the correction factor was (g - 1) times Box's c1, so the statistic was too small with three or more groups, and on the sweep's design, x uniform and so light-tailed, Box's chi-square and his F both reject 0.017 of the time at 5000 replications, against 0.016 with the factor of 0.15.0. The factor is now Box's: two groups give identical() values, and three or more a larger statistic, 140.9430 on iris by species, the value of biotools::boxM(), heplots::boxM() and rstatix::box_m(), where it was 135.2229 (announced in bold). Under Gaussian data Box's chi-square is inside the release gate in 60 of 67 cells; it is liberal when the groups are small for the number of variables (0.062 to 0.106 at 5% in the four cells of groups of 10 with 4 or 6 variables outside the gate), not robust to heavy tails (t5 coordinates: 0.26 to 0.48) and conservative with light tails, all now on its help page. The F approximation, closer to the level in the small-sample cells, changes the statistic and its parameters, and was not adopted.
  • performSzroeterTest() on a weighted fit. In inst/validation/harrison-mccabe-size-power.R, on a correctly weighted fit with Gaussian errors, it rejects 0.067 of the time at 40 observations, outside the release gate, and 0.056 and 0.055 at 100 and 400; without weights it is inside the gate at all three sizes. Its null mean and variance, (n + 1) / 2 and (n^2 - 1) / (6 n), do not depend on the design, and in that weighted design the start of the ordering has the larger leverages. Either take its null distribution from the weighted design, as performHarrisonMcCabeTest() does, or document the restriction on the help page. Done in 0.16.0 (decided 2026-10-25: the exact moments, the normal reference kept). inst/validation/szroeter-design-null-size.R compared, on the samples of that study, the moments of h given the design with the exact null from the eigenvalues: within 0.0006 of each other in every cell, and both inside the gate in all six Gaussian cells. The test now standardises h with E h = tr(M A) / nu and Var h = 2 (nu tr(M A M A) - tr(M A)^2) / (nu^2 (nu + 2)) of the weighted, ordered design, taken as performHarrisonMcCabeTest() takes it, at a cost of O(n k^2): weighted 0.0494, 0.0470 and 0.0498 at 40, 100 and 400 observations, unweighted 0.0482, 0.0498 and 0.0494. h is identical(); every Q and p-value changes (announced in bold), unweighted by a median 0.006 at 40 observations and 0.0006 at 400.
  • The wild bootstrap without a multiplier. performWildBootstrapTest() already resamples the leverage-standardised residuals i.i.d., which imposes the null hypothesis, and multiplies them by v on top. In inst/validation/wild-multiplier-size.R it is outside the release gate with centred exponential errors at 30, 50 and 100 observations with either multiplier. An exploratory check by the reviewer of that study, on seeds of its own and not to evidence grade, suggests that the excess comes from the multiplier step: with no multiplier (v = 1, a plain residual bootstrap) the rejection rates were 0.048, 0.053 and 0.062 where Rademacher's gave 0.064, 0.067 and 0.077 on the same samples, with exponential errors at 50 and 30 observations, paired differences of about -1.4 to -1.6 points with standard errors of about 0.0018. Measure a no-multiplier option to evidence grade in the design of that study. The option, and still more a default, changes values the function returns, so it is the author's decision. The help page's claim about heavy-tailed errors is corrected in #125. Measured in 0.16.0 by inst/validation/wild-no-multiplier-size.R, on the samples of that study at 5000 replications and at 200 observations as well: with exponential errors Rademacher's multiplier rejects 0.0820, 0.0734, 0.0608 and 0.0526 of the time at 30, 50, 100 and 200 observations, Mammen's 0.0782, 0.0698, 0.0586 and 0.0534, and no multiplier 0.0648, 0.0562, 0.0482 and 0.0466, inside the gate from 50 observations where the multipliers are from 200; with Gaussian and t5 errors all three are inside it. No multiplier costs 1.4 points of power at 30 observations and none at 100. Decided on 2026-10-25: document it now, with no change of value in 0.16.0 (the help page's Size section has the table), and offer it as distribution = "none" in 1.0.0, listed there.
  • runAdvancedDiagnostics(tests = "bp") stops with "Unsupported test requested: bp", although its help page offers c("white", "bp") as a subset. The check compares the names asked for with the values of its lookup table ("white", "breusch_pagan") rather than with its names, which include "bp". Found on 2026-10-15 and recorded, not fixed, in 0.15.0. Done in 0.16.0: the names asked for are matched to the names of the table, so "bp" runs what "breusch_pagan" runs, under the name breusch_pagan, as its help page says and as rvalidate_against_reference() already took "bp"; every other name is refused with the message it had (announced in bold).
  • runAdvancedDiagnostics(auto_enhance = FALSE) silently runs the classical Breusch–Pagan test. auto_enhance also sets studentize for performBPTestRobust(), so turning the small-sample bootstrap off turns Koenker's studentization off with it, and the result, named breusch_pagan either way, then assumes normal errors, which the default battery stopped doing in 0.15.0. Either separate the two settings or say on the help page what auto_enhance = FALSE runs. Found on 2026-10-15 and recorded, not fixed, in 0.15.0. Done in 0.16.0 (decided 2026-10-23): the two settings are separate. auto_enhance turns the small-sample bootstrap on and off and nothing else, and the Breusch-Pagan entry is Koenker's studentized statistic either way, the default of performBPTestRobust(); the classical test is still performBPTestRobust(studentize = FALSE). With auto_enhance = FALSE the entry changes, from 3.135 (p = 0.2086) to 3.086 (p = 0.2138) on lm(mpg ~ wt + qsec, data = mtcars) (announced in bold).
  • checkModel(<glm>, data, enhanced = TRUE) stops with "missing value where TRUE/FALSE needed", Gaussian and binomial alike: the enhanced check reads the R-squared of summary(), which a glm does not have. Since 0.15.0 performWhiteTestStreaming() refuses a glm before calling it; the check itself should refuse it with a clear message or skip that step. Found on 2026-10-15 and recorded, not fixed. Done in 0.16.0 by the model-eligibility work (#136), and found done in the 0.16.0 release audit: the check reads the R-squared of the least-squares fit, so on a Gaussian glm it warns about a near-perfect fit as on the lm, and on a glm of another family it skips that check (R/utils.R, test-model-eligibility.R).
  • A weighted fit without data. On a fit lm(..., weights = w) whose w is a column of the data, the tests that rebuild the design with model.matrix(model, data = data) (performWhiteTest(), performBPTest(), performKoenkerTest() and others) stop with "object 'w' not found" when data is omitted, as they do when given the model frame, which holds the weights as (weights). Found on 2026-10-15; no value changes. The eight are performWhiteTest(), performWhiteTestRobust(), performWhiteTestBootstrap(), performBPTest() and performBreuschPaganTest(), performKoenkerTest(), performRankPermutationTest() and performHighDimensionalTest(), and a fit with offset = o, held as (offset), stops them the same way (found in the 0.15.0 release audit). Through runHeteroTests() and runDiagnostics() such a fit does not stop: White's and Koenker's tests fail and both names hold the Cook-Weisberg test the runner falls back on (below). A fix changes what those runner calls return. Done in 0.16.0 by the first step of the model context (below): the five tests that rebuilt the design read it, the weights and the offset from the fit, so the eight give the value they give with data, and the runners return White's and Koenker's tests under their names (announced in bold).
  • The fallback of runHeteroTests(). When a test stops, the runner runs the substitute registered for it in .ht_fallback_registry (for "white": White's test without cross products, then the classical Breusch-Pagan, Koenker and Cook-Weisberg tests) and returns it under the name asked for, tagged by attr(, "diagnostic") and attr(, "extras")$status == "fallback" and announced in a WARN log line. $white can then hold another test. Documented on ?runHeteroTests since the 0.15.0 release audit; whether a failed test should rather be returned as failed is an interface decision for 1.0.0. Removing the fallback changes what such calls return. compareModelDiagnostics() reports NA, with a warning, when attr(, "diagnostic") names another test, but White's test without cross products keeps the name "white", and its table has no place for the tag: on 22 observations of six regressors, whose White's test is refused, it reports 15.98, the statistic of the fallback, in the column white, with no tag and no warning. Found by the review of the follow-up of N2; the same decision settles it, and until then it does not change. Decided on 2026-10-23, for 1.0.0: a failed test keeps its name and is returned as failed, and the substitute is returned under its own name (the item under 1.0.0). Ticked in the 0.16.0 release: the decision is taken, and the work is that item.
  • A glm of another family through the registry. The registry names deprecated in 0.14.0, "ncv" and "student_bp", called .ncv_test() and .studentized_bp_test() directly, and the refusal of a glm that is not a least-squares fit was in performNCVTest() and performStudentizedBPTest() only. So runHeteroTests(m, d, tests = "ncv"), cachedTest("ncv", m, d) and runHeteroTestsParallel() returned a value for m <- glm(high ~ x1 + x2, family = binomial), and, because "ncv" is the last fallback of "white", "koenker" and "breusch_pagan", runHeteroTests(m, d) with its default battery returned the Cook-Weisberg statistic under the names white and koenker. Found in the 0.15.0 release audit. Done in 0.15.0 (decided 2026-10-16): the two registry entries, the "student_bp" entry of test_factory and the "studentized_bp" key of rvalidate_against_reference() refuse such a fit with the message of those functions, so runHeteroTests(m, d) stops with White's refusal and no name holds a fallback computed on it. On an lm, a Gaussian glm and a weighted lm every value is identical() to before.
  • The panel tests on a fit that dropped rows. On lm(y ~ x1 + x2, data = d) with NA in three rows of x2, performPesaranTest(m, d, "id", "time") stops with "requires data with 117 observations to match the fitted model, got 120" (since the 0.15.0 guard on the order of the rows; "arguments imply differing number of rows: 120, 117" before it), and with only the complete rows with "number of items to replace is not a multiple of replacement length"; performBPRandomEffectsTest(m, d, "id") refuses the full data with "requires data with 117 observations to match the fitted model, got 120", where the other tests match the rows by name. As in 0.14.0; found in the 0.15.0 release audit. A fix makes such calls return a value. Done in 0.16.0 by the fifth step of the model context for the random-effects test and the scatter diagnostic, which give the value of the fit to the complete rows (2.016, p = 0.156, for the random-effects test) on the full data, shuffled or as a tibble. Pesaran's test now reaches the panel of the fit, which is unbalanced, and refuses it (the item below); on a fit that drops every row of a unit, whose panel stays balanced, it gives the value of the fit to the complete rows. Since the pull request of N12 (#160) it gives the value of the fit to the complete rows on the unbalanced panel too: 0.189 (p = 0.850) with x2 missing in rows 3, 7 and 11 of contract_data(), the value of plm::pcdtest() on the rows of the fit.
  • Pesaran's test on an unbalanced panel (N12 of the design of the model context). performPesaranTest() fills a matrix of periods by units with the residuals of each unit in the order of its periods, and a unit observed in fewer periods than the panel holds does not fit its column: the assignment stops with "number of items to replace is not a multiple of replacement length", or, when the number of periods of the unit divides that of the panel, recycles its residuals over the periods it lacks and returns a wrong value without an error. On the 120 observations of contract_data() without the first five rows, unit 1 keeps 5 of its 10 periods, and the test returns 0.234 (p = 0.815), where plm::pcdtest(model = "pooling"), which correlates each pair of units over the periods both hold (Pesaran 2004, Section 9), gives -0.373. As in 0.14.0; the fifth step of the model context lets more data reach it: the full data of a fit that dropped half the rows of a unit, which the test refused for their number of rows, would give the recycled value, as the complete rows of such a fit did. Decided by the author on 2026-10-24: refuse now, fix later. Since the fifth step performPesaranTest() refuses a panel whose units are not all observed at the same periods ("needs a balanced panel, every unit observed at the same periods", of class heteroTests_model_context_error), on every route, runPanelTests() included, so it never returns the recycled value, and the error of the assignment can no longer be reached; a fit that drops every row of a unit keeps a balanced panel and is tested. The fix, a pull request of its own, computes the statistic of an unbalanced panel on the periods each pair of units shares, as plm::pcdtest() does (Pesaran 2004, Section 9), and removes the refusal; it changes what such calls return, announced in bold. Done in 0.16.0: CD = sqrt(1 / P) sum_{i<j} sqrt(T_ij) rho_ij, with rho_ij the correlation of the residuals of units i and j over the T_ij periods both are observed at, each centred at its mean over them, and P the number of pairs that share two periods or more, as plm::pcdtest(test = "cd") computes it; a pair that shares fewer is left out with a warning, as plm leaves it out, and a panel in which no pair shares two periods is refused; a correlation that is not defined, a unit constant over the periods of a pair, adds nothing and the pair stays in P, with a warning, where plm returns NA. The example gives -0.373 (p = 0.709), and every value equals plm's to 1e-10 on 252 panels, 228 of them unbalanced, in 5,040 comparisons: late entry, early exit, gaps, 1 to 20 rows missing at random, a unit observed once, balanced panels, weighted fits and a Gaussian glm, the full data of a fit with na.omit, and through runPanelTests(). On a balanced panel every value is identical() to before. A fit with na.action = na.exclude that dropped rows is read by position until step 9, and an unbalanced panel of the rows of data stays refused there, with a message that points to na.omit; one that dropped no row is tested as any other. Since step 9 (0.17.0) such a fit is tested as the same fit with na.omit, its panel balanced or not, and the refusal is gone.
  • The wild bootstrap in the advice for a small sample. 0.15.0 corrected the warning of warnDiagnosticAssumptions() for a sample of fewer than 100 observations, but two other strings still recommend the wild bootstrap for such a sample: suggestDiagnosticsForProfile() adds it with the rationale "Bootstrap inference performs well in small samples." (R/intelligent_recommendations.R:183), and step 1 of buildDiagnosticDecisionTree() says "Emphasise wild bootstrap and rank permutation tests." (:575). In inst/validation/wild-multiplier-size.R the wild bootstrap does not hold its level with skewed errors at 30, 50 and 100 observations: with centred exponential errors it rejects 0.082, 0.073 and 0.061 of the time at the 5% level with the Rademacher multiplier. Say what the warning says, or drop the claim. Recorded, not fixed, in 0.15.0; only the text changes. Done in 0.16.0: both say what the warning says. The rationale reads "In the package's validation study, at 30 to 100 observations, the wild bootstrap rejected a true null hypothesis 4.3% to 5.1% of the time at the 5% level with normal and t5 errors, but 6% to 8% of the time with skewed errors. ...", and step 1 "Add the wild bootstrap and rank permutation tests; with skewed errors ...", the caution of the warning (rwild_small_sample_caution()). The same tests are recommended and the same steps given (test-intelligent-recommendations.R).
  • performFlignerKilleenTest() and a tie at the median. The statistic depends on rounding when the number of residuals is even: the two in the middle tie at exactly half their difference from the median, and which ranks first decides about 1e-6 of the statistic. Found on 2026-10-15 comparing a Gaussian glm with the same lm. Average the ranks of values tied to within a tolerance. Changes a value, by that much. Done in 0.16.0: absolute deviations from the group medians closer than 8 * eps * s, with eps = .Machine$double.eps and s the largest absolute residual of the group (the smaller of the two for deviations of different groups), are ranked as tied (rfligner_rounding_ties()). The centring opens a gap of at most eps * s between the two in the middle of a group. The scale is that of the group, so that a group far from zero does not widen the tolerance of the others, as one shifted by 1e8 did with the largest residual of all (decided after the review of #156). On lm(y ~ x1 + x2) with four groups of 30 the lm gave 9.3670076662 and the Gaussian glm 9.3670316854; both give 9.3670316854, identical(), as the matrix of the model context now checks (context_fligner_tolerance, 1e-4 before, 1e-10 now; identical() on Windows). In the identity harness of the model context (scripts/model-context-identity.R), 128 of the 351 calls of the test change, by 1.1e-6 to 4.4e-6 of the statistic, and the 91 of them whose cell has another fit of the model as its reference are now identical() to it; the other 19,628 calls are identical() to main (test-fligner-killeen-ties.R, test-pass-b-reference.R).
  • performFlignerKilleenTest() on residuals whose ties the fit broke. The tolerance of the item above is that of the centring. On an integer response regressed on a factor, residuals that are equal in exact arithmetic are computed equal by the Gaussian glm but not by the lm, whose QR decomposition leaves them apart by more than the tolerance, which catches some of them only. In 20 fits of y <- rpois(n, 3) on four equal groups the largest gap was 17 times eps * max(abs(r)) at 60 observations, 400 times at 600 and 6,900 times at 6,000, and 208 times at 60 with rpois(n, 300): the rounding of the fit is relative to the size of the response rather than of the residuals, and grows with the number of observations, so no fixed multiple of eps * max(abs(r)) covers it. The statistic of the lm then stays off that of exact ties: from set.seed(3) with 60 observations in groups of 15, 6.10, where the glm and stats::fligner.test() of the responses give 6.66 (4.49 before 0.16.0). Found in 0.16.0 with the item above. Done in 0.16.0, at the source (decided after the review of #156): the test ranks the residuals sqrt(w) * (y - X b - o), computed observation by observation with X b summed in the same order for every row (rfligner_residuals()), as the Gaussian glm computes them, then applies the tolerance of the item above; where neither changes a rank it passes the residuals of the fit on as before. Observations with the same values of the model's variables, weight and offset are given one residual, that of the first of them (rfligner_same_observations()), where their residuals agree to within 64 times eps of the size of the numbers they are computed from, so that a term of the row position (seq_along(x), cumsum(x)) keeps them apart; this needs data, or a model frame that holds the variables. It is done since poly() builds its basis with a QR decomposition of its own and can give them design rows that differ in their last bits (from the confirmation review of #156: on 70 observations repeating 30, y ~ poly(x, 3) gave 0.80190 where y ~ x + I(x^2) + I(x^3) gives 0.91385; both now give 0.91385). On 480 fits of lm(y ~ g) with integer responses (rpois(n, 3), rpois(n, 300), 1e4 + rpois(n, 3), four groups, balanced and unbalanced, 60 to 60,000 observations, all accepted: the smallest group has 6 observations) the statistic equals that of exact ties in all 480, where through the package it did in none before 0.16.0 and in 38 with the tolerance alone (at 99d446e). On sleep, PlantGrowth, warpbreaks, InsectSprays and iris[-c(50, 100, 150), ] it equals stats::fligner.test() of the responses scaled to integers (sleep 0.154 before, 0.213 now; warpbreaks 3.44 and 3.90). On 3,000 continuous fits of 20 to 10,000 observations no two deviations that differ in exact arithmetic are tied, and the lm and the glm give the same value, unless the residuals of a group are 1e8 times their spread: there the tolerance tied such deviations in 41 of 200 fits of 1,000 and 10,000 observations, and the lm and the glm differed in 4. Over a corpus of 55 calls the 20 in which no rank changes are identical() to before; 14 of the 17 with repeated observations change, by up to 38%, and 21 of the 38 with continuous responses, by 7.9e-7 to 3.2e-5 of the statistic.
  • A Gaussian glm with a zero prior weight. The tests that read the Pearson residuals through rpearson_residuals() refuse an lm with a zero weight, whose Pearson residual is an artificial zero, but not the same glm: rpearson_residuals() reads rprior_weights(), which returns no weights for a glm. The Koenker, White, Glejser, Spearman, GQ and wild bootstrap tests return a value for it, and performWhiteTestBootstrap() stops with "Model residuals must be finite". Suggested fix: have rpearson_residuals() read rleast_squares_weights() instead of rprior_weights(). Two related gaps: plotDiagnostics() reads rprior_weights() too, so a weighted Gaussian glm is not labelled "(weighted residuals)", and performRESETTest() does not refuse a zero weight, even on an lm. Found on 2026-10-15 and recorded, not fixed, in 0.15.0, as decided that day. A fix changes what such fits return. Done in 0.16.0 as suggested: the 32 tests that returned a value, and the scatter diagnostic, refuse such a glm with the message of the lm, and plotBeforeAfter() labels a weighted Gaussian glm. performRESETTest() keeps accepting a zero weight, on an lm and a glm: its two fits are weighted least squares, which drop the observation, and its F statistic and degrees of freedom are those of the fit to the positive weights (test-model-eligibility.R). Decided on 2026-10-17: RESET keeps accepting a zero weight, the documented exception on the Supported models page.
  • Found by the model-eligibility work (the Supported models page), recorded, not fixed, as they change what a test computes rather than which models it accepts. Those done in 0.16.0 are below; the others are under 0.17.0 and 0.18.0:
    • test_factory$run_test("szroeter", ...) always stops with "argument \"order_by\" is missing": the factory registers performSzroeterTest(), which needs an ordering, where the registry name "szroeter" orders by the fitted values. Register the same function. An error becomes a value. Not "always": run_test() passes its ... on, so test_factory$run_test("szroeter", m, d, order_by = "x") gives performSzroeterTest(m, d, order_by = "x"), and only a call without order_by stops. Registering the function of the registry would refuse that argument, so a call that works would stop. Done in 0.16.0 instead as the factory treats a test that needs more than the model and the data, as "spatial_hetero" asks for its neighbour list: without order_by the entry refuses the call with a message that asks for it and shows how to give it, and with order_by it returns what it returned. It orders by nothing of its own. No value changes; ordering by the fitted values when order_by is not given, as the registry does, would make the refusal a value, and is the author's decision.
    • The three tests that refused every glm, performWhiteTestStreaming(), performHarrisonMcCabeTest() and performVarianceFormTest(), refused a Gaussian glm with the identity link too, which is a least-squares fit the others accept. Done in 0.16.0. The author's decision was to accept it if the literature supports it, and it does: the Supported models page sets out the argument and the references. They accept it and give the value of the lm. The White streaming test's checks read the R-squared of the least-squares fit, and the other two read the prior weights. With it, the checks of every test refuse a near-perfect Gaussian glm as the lm, and fitWLS() refits a Gaussian glm as the lm it is and refuses any other glm. Refusals became values.
    • performWhiteTest() counted one degree of freedom too many when its auxiliary columns span the constant: it took the rank of the auxiliary design without the intercept, where the regression has one. With a factor under contr.sum, as in lm(y ~ x1 + f, contrasts = list(f = "contr.sum")) with four levels, the squares of its columns sum to a constant, and the df was 9 where lmtest::bptest() on the same terms gives 8, with the same statistic. The review of the model-eligibility work, which found it, reported 15 against 14 with nested Helmert contrasts and 24 against 23 on a design with an empty cell (the empty cell of the Supported models page, under treatment contrasts, gives the right 23). Done in 0.16.0, outside the model context (N2, decision 22 of its design): the degrees of freedom are the rank of the auxiliary design with its constant, minus one, and the columns the constant makes redundant are dropped, so that every route to White's test that gives a value gives that of lmtest::bptest() on the same terms; the streaming test stops on these designs, whose auxiliary columns are collinear with its intercept. Changes p-values; on a nearly collinear auxiliary design, such as that of a smooth term, the statistic too.
    • The review of N2 found three more things, decided by the author on 2026-10-21. The rank of [1, Z] depended on where the regressors were located: with x = 1e4 + u, or a date written as YYYYMMDD, the square of x was taken for a combination of the intercept and x and dropped (27.41 on 4 degrees of freedom where the test of the same model at zero gives 27.43 on 5). An auxiliary regression with as many independent terms as observations fitted the squared residuals exactly, and the test returned the number of observations as its statistic. And a regressor named like a column the test generates, a beside a_sq, stopped the auxiliary regression, which was fitted from a formula on the names. Done in 0.16.0: the rank is decided on the auxiliary design of the centred regressors, the regression fitted on the columns as they are unless that fit would lose one of them, a saturated auxiliary regression refused (by the streaming test too; the runner falls back on White's test without cross products), and the regression fitted with lm.fit(). Changes p-values on designs with a regressor far from zero, and refuses calls that returned a value.
    • The review of the follow-up of N2 found that the refusal of a saturated auxiliary regression missed an exact fit with fewer terms than observations. On a paired design, lm(y ~ treat + subject) with two observations of each of 30 subjects, the residuals of a subject sum to zero, so their squares are equal and a combination of the subject dummies: every route to White's test gave 60 on 30 degrees of freedom (p = 0.0009), and the runner rejected at the 5% level in 200 of 200 homoscedastic draws. With a factor of 10 levels of two observations beside x1, 20 observations, the refusal of the saturated regression with the products let the runner fall back on White's test without them, an exact fit too: 20 on 11 (p = 0.045). Done in 0.16.0: White's test refuses an auxiliary regression whose residual sum of squares is at most sqrt(.Machine$double.eps) of the total, with the message of the saturated one, on every route; when the regressors alone fit the squared residuals exactly, the runner takes no fallback, and stops. Refuses calls that returned a value.
    • The refusal of White's exact fit covers that fit only: one step away from it, and on any fit with fixed effects for small groups, White's, Koenker's and the Breusch-Pagan tests over-reject on homoscedastic data. In 2,000 draws of lm(y ~ x + subject) they rejected at the 5% level in 51%, 57% and 40% with 30 subjects of three observations, and in 24%, 24% and 25% with 100 subjects of eight; at a fixed number of observations per subject the size goes to 1 as the subjects grow (inst/validation/fixed-effects-size.R). The excess comes from the subject dummies in the auxiliary regression (inst/validation/fixed-effects-dummies.R). Done in 0.16.0, decided by the author on 2026-10-23: the three tests warn when a term of the model that holds a factor has more levels than observations per level, L^2 > n, counting the cells of an interaction, with their values unchanged; on a balanced design the rule is G <= T, which misclassified 1 of 69 designs of the study for the three tests together. The routes that compute the same statistics warn too, and the White bootstrap, the wild bootstrap and bootstrapTestStatistic(), which held their level on those designs, do not (inst/validation/fixed-effects-variants-size.R). A warning, not a correction: testing against the regressors without the dummies, and a test built for fixed effects, are for after 1.0.0.
    • Three more tests ignore an offset, besides the studentized statistic above, so that on lm(y ~ x1 + x2, offset = o), or with + offset(o), they do not give the value of the same model fitted to y - o: performRESETTest() refits the augmented model without it (R/performRESETTest.R:50), with either form of offset; performGQTest() refits each segment of an unweighted fit from the formula, which drops an offset given as an argument (R/performGQTest.R:132), and those of a weighted fit with lm.wfit() and no offset at all; and performQuantileRegressionTest() fits quantreg::rq(), which ignores any. With o = 0.8 u, u a regressor outside the model, RESET gave a negative F where the model of y - o gives 4.49 (p = 0.013), GQ 1.63 against 2.38 and the quantile test 0.90 against 3.26 (found in the review of the model-eligibility work). Decided on 2026-10-17: until they are corrected, these three and the studentized statistic refuse exactly the offsets they leave out (RESET, the quantile test and the studentized statistic any offset; GQ an offset given as an argument, and any offset on a weighted fit), with class heteroTests_offset_error, and the runners do not fall back on another test for it (rrefuse_offset()). The offset fix of 0.16.0 makes them account for the offset, as rweighted_refit() and the bootstraps do, which turns these refusals back into values; announce it in bold. Done in 0.16.0, by step 2 of the model context (below): GQ and the quantile test give the value of the model of y - o (2.38 and 3.26), and RESET the nested F test of the model with its offset (decision 19), F = 0.112 (p = 0.894) on that fit, where RESET of the model of y - o, another test, gives 4.49; rrefuse_offset() and the class heteroTests_offset_error are gone.
  • Found by the review of the Gaussian glm work (#136), recorded, not fixed (the other is under 0.17.0):
    • performHarrisonMcCabeTest() is not consistent with itself on a fit whose rank was decided with a tolerance below 1e-7: a Gaussian glm, as glm.fit() decides it with min(1e-7, epsilon / 1000), 1e-11 by default, or lm(..., tol = 1e-11). On a nearly collinear design, such as x3 = x1 + 1e-9 * rnorm(n), its residuals are those of the fit of rank 4, and its null distribution is computed from qr() of the design at the default 1e-7 (R/performHarrisonMcCabeTest.R:239), of rank 3. Take the rank, and the columns, from the fit. Changes a p-value. Done in 0.16.0 by the third step of the model context (decision 21): the test takes the rank of the fit, at its tolerance, and the glm above gives p 0.002273 where it gave 0.002288.
  • Defects found during the 0.12.0 and 0.13.0 work, left for this release so that 0.14.0 changes no value. Each fix that changes a value is announced in bold in NEWS.md. Those done are below; the others are under 0.17.0 and 0.18.0.
    • fitWLS() keeps the original weights in the model frame of its result, so performStudentizedBPTest() on a fitWLS() fit reads stale weights: 14.81, against 1.73 for the equivalent lm(weights = estimated) fit. Done in 0.15.0 (decided 2026-10-16, in #129): .studentized_bp_test() reads the weights of the fit, through rleast_squares_weights(), rather than those of its model frame, which is unchanged. It changes the values of performStudentizedBPTest(), performBPTestRobust() and the registered "student_bp" on a fitWLS() fit only, and so the Breusch-Pagan entry of runAdvancedDiagnostics() on one.
    • .studentized_bp_test(), behind performStudentizedBPTest(), performBPTestRobust(studentize = TRUE) and the registered "student_bp", drops every offset, given as an argument or written in the formula: it refits the model with lm.wfit() and no offset. On lm(y ~ x + z, offset = o) in the design of tests/testthat/test-fitwls-reestimation.R it gives 19.91 where performKoenkerTest() gives 18.79. Found in the review of #129. Changes a value. Since 2026-10-17 (decided that day) it refuses a model with an offset until it is fixed, with the three tests of the item on offsets above; the fix turns the refusal back into a value. Done in 0.16.0, by step 2 of the model context: its refit carries the offset, and it gives Koenker's 18.79.
    • performWildBootstrapTest() and performWhiteTestBootstrap() accepted a non-Gaussian glm and returned a meaningless result. Done in 0.15.0 with the input validation: they refuse it, and read the prior weights of a Gaussian glm.
    • The caches of runHeteroTests(use_cache = TRUE) (about 33 KB per new model) and cachedTest() (about 25 KB per call) grow without bound, as the validation cache did before #97. No value changes. Done in 0.16.0, with the contract decided on 2026-10-21: each cache holds at most getOption("heteroTests.cache_size", 100) results, the validation cache getOption("heteroTests.validation_cache_size", 1000), each dropping the entry used least recently, and 0 turns a cache off; an entry keeps no copy of the model. The key holds everything that determines a result, so two fits with the same coefficients and residuals no longer share one (that changed values, announced in bold), and a test that draws random numbers is never cached.
    • About 0.8 KB per test call on a new model still persists after the validation cache is cleared, as seen in long simulations. No value changes. Done in 0.16.0: they were the keys of the caches of cachedTest() and runHeteroTests(), the names of variables of an environment, which R never frees (about 0.2 to 0.3 KB each, three per call of runHeteroTests()). The keys are a character vector now, as those of the validation cache since #97, and a cleared cache leaves nothing.
    • On a rank-deficient fit (z2 = 2 * z, the design in tests/testthat/test-koenker-studentized.R) both performKoenkerTest() and performStudentizedBPTest() refuse with "Model coefficients must be finite…", where lmtest::bptest() returns a statistic (20.87, df 2). Decide whether to compute on the estimable subspace or keep the refusal with a clearer message. Done in 0.16.0 on the estimable subspace, for every test, as the brief of the model-eligibility work asked: every test that accepts the model without the aliased columns accepts the rank-deficient one, and gives its statistic and p-value (to the last digit on the four fits of test-model-eligibility.R); Koenker's is 20.87 on 2 df. No test needs every coefficient, so none refuses; the quantile regression test fits quantreg::rq() to the columns that were not aliased. validateTestInputs(), functions defined outside the package that a user registers with test_factory, and generateHeteroRecommendations() still refuse it, with a message that names the aliased coefficients. The power form of performVarianceFormTest() takes the logarithms of every column, the aliased ones included, so its value is not that of the reduced model, and does not depend on the order of the terms (kept so, decided in the review). Decided on 2026-10-17: computed on the estimable part of the design.
  • The model context. One internal, validated object per call, built from the stored fit, from which the tests read the response, the design, the residuals, the weights, the offset and the rank, and which matches the rows of data to the observations of the fit with the guard of 0.15.0 before anything is read from them: named data in any order, numbered data in fit order. The design, the contract, the test matrix and the decisions of 2026-10-16 and 2026-10-18 are on the site, docs/design/model-context.md. Each step is one pull request, checked by scripts/model-context-identity.R against main (every call identical() but the cells of the defects the step closes) and pinned by tests/testthat/test-model-context.R, whose context_migrated lists the functions moved so far. Steps 1 to 7 are done in 0.16.0; steps 8 to 11 are under 0.17.0.
    • 1. The context (R/model_context.R) and the guard split in two (rdata_fit_rows(), rcheck_rows_at()); performWhiteTest(), performBPTest() and performBreuschPaganTest(), performKoenkerTest(), performRankPermutationTest() and performHighDimensionalTest() built on it, and through them the base statistic of performWhiteTestRobust() and performBPTestRobust(studentize = FALSE). Closes B5, B12 (direct calls and the runner), B14, B15, N7, R4/N9 and R7 for these, and L1 and L2. The refusals of the context are final in the runners (decision 6). performWhiteTestBootstrap() keeps the computation of 0.15.0 for its observed statistic and its replicates (rwhite_test_rebuilt_design()); bootstrapTestStatistic() and performWhiteTestRobust(bootstrap = TRUE) refuse a fit whose frame cannot be rebuilt from data. Done in 0.16.0.
    • 2. The offsets: the studentized Breusch-Pagan statistic, RESET (the nested F test of the offset model, decision 19, with one evidence cell of 5000 replications in offset-refits-size.R: it rejects 0.0492, inside the release gate), Goldfeld-Quandt on the stored design (decision 20) and the quantile test on y - offset; rrefuse_offset() goes. Closes B7, N1, N6 for RESET, R5, and for these four the classes step 1 closed for its five (B5, B12, B14, B15, L1, L2, N7, R4). RESET reads nothing from data and builds its context without it, keeping its check of data until step 6; the four refuse an mgcv::gam() fit with smooth terms until step 11. Done in 0.16.0.
    • 3. Glejser, Park, Szroeter with the registered "szroeter", which reads its fitted values from the context, Harrison-McCabe (its rank at the fit's tolerance, decision 21), Hartley and the spatial test. Closes B14, L1, L2 and M1 for these, N4, and N17 for the five that read a column of data; B15 gave values already. The six test a gam with smooth terms as before. Done in 0.16.0.
      • Found in step 3 (N17): the residuals of an mgcv::gam() fit carry no names, and the tests that read a column of data beyond the model match data to them by position, so named data in another order than the fit give a wrong value without an error. Step 3 closes it for its five, and step 4 for the five group tests (Levene's, Brown-Forsythe's, Fligner-Killeen's, Bartlett's and O'Brien's); the panel tests have it until step 5, the three streaming tests until step 7 and the replicates of performWhiteTestBootstrap() until step 8.
    • 4. The five group tests; their copies of the alignment go, and they leave out the observations whose group is missing, with the warning of before. Closes the character-named fit with its tibble and the complete rows renumbered (B14), R7, L1, L2 and M1 for group, and N17 for these; B12 and B15 gave values already. A fit with model = FALSE whose data are gone given a shuffled tibble, and an edited fit (M8), from which they computed a value, are refused, as by the first fifteen. The five test a gam with smooth terms as before. Done in 0.16.0.
    • 5. The panel tests, the scatter diagnostic and runPanelTests(), with id and time read at the confirmed rows; repeated (id, time) pairs refused (decision 13). Closes B3, B4, B16, R2, and N17 for these; a fit with model = FALSE whose data are gone given a shuffled tibble, and an edited fit (M8), from which they computed a value, are refused, as by the first twenty. On a fit that dropped some rows of some units Pesaran's test refused the unbalanced panel (N12, the item above), and since the pull request of N12 computes it from the periods each pair of units shares. A fit with na.action = na.exclude is read as before, by position, until step 9. Done in 0.16.0. Step 9 reads it as the same fit with na.omit.
    • 6. The tests computed from the model alone, Cook-Weisberg with var_formula, performVarianceFormTest() and fitWLS(data, var_formula), and the check of data RESET keeps until then (rcheck_data_argument()). Closes B9, R3, N6 for these. The nine tests computed from the model alone and fitWLS() build the context, which refuses a stale model = FALSE frame (N6), a multivariate lm, an edited fit (M8; not fitWLS()), and a fit whose data are gone unless data gives its response and fitted values. They read nothing from data, and match it through the guard as every test does, RESET and fitWLS() without var_formula too (decision 31, decided by the author on 2026-10-26: one rule for every test, which ends decision 24): they refuse numbered data in another order than the fit, rows in front of those of the fit and repeated row names, from which they computed the value of the fit, and take the complete rows of a fit that dropped rows numbered again, which the check of 0.15.0 refused. A second formula (var_formula, against) is evaluated on the rows the alignment confirmed (B9), and its variables come from data or the model frame, never from the workspace (R3, decision 9). Done in 0.16.0.
    • 7. The three streaming tests, chunk by chunk from the stored design. Closes B6, and B5, B14, L1, L2, N7, N17 and R4 for these; B12 and B15 gave values already. An edited fit (M8) and a fit with model = FALSE whose data are gone given a shuffled tibble, from which they computed a value, are refused, as by the tests built on the context. A gam with smooth terms is tested as before, on the design of the variables of its formula (the item of the review of N2, under 0.17.0). Done in 0.16.0.
  • print() and summary() for the results of runHeteroTests(). An addition, outside "no API change": exported S3 methods print() and summary() for hetero_test_suite and hetero_grouped_suite, and print() and coef() for their summaries, approved by the author on 2026-10-21, with the significance codes of summary.lm(). A suite printed the list, the fitted model among its attributes, and had the summary of a list. Done in 0.16.0 (?summary.hetero_test_suite): a table with one row per test, notes for the tests that fell back, were refused or failed, and coef(summary(s)) as coef(summary(fit)) is for an lm. With it a test the runner runs that reported "model" as its data.name, White's test and four others, reports the formula of the model, and a suite whose model the runner fitted itself says so in attr(, "model_origin"); no statistic changes.
  • The quantile regression test on a model with one regressor. On lm(y ~ x1) with 60 Gaussian observations, runHeteroTests(m, d, tests = "quantile_regression") stops with "subscript out of bounds" and returns the rank permutation test, its fallback, under the name asked for; with two regressors or more, factors, an interaction or a poly() term it gives a value. Found by the review of the print and summary methods on 2026-10-22 and reproduced on main, so not caused by them. Explain it, and fix it or refuse such a model with a clear message. Explained and fixed in 0.16.0. The quantile regressions are fitted with the design of the fit as one matrix column, .design, and the formula names a coefficient after that column followed by the name of its column, .designx1, except when the matrix has one column, whose coefficient is called .design alone. The test removed .design from the names, so the slope of a model with one regressor had an empty name, and reading it by name for estimate stopped. The statistic was already computed, and right: it is anova() of quantreg::rq(y ~ x1), which test-quantile-joint-reference.R now checks. The coefficients are named after the columns of the design, so such a model gives its value: on lm(stations ~ mag, data = quakes), F = 57.57 on 1 and 1999 degrees of freedom (p = 5.0e-14), where the test stopped and runHeteroTests() returned the rank permutation test. So does any design with one column beside the intercept once aliased columns are dropped, such as a factor of two levels; a design with two columns or more beside it gives the value it gave, identical() (announced in bold). An exploratory size check, not to evidence grade (2,000 replications per cell, the design of full-sweep-size-power.R with one regressor), puts it below its level, as the two-regressor design is under t5 errors: 0.029 and 0.040 at 60 and 150 Gaussian observations, 0.021 and 0.032 under t5 errors. A model without an intercept is still refused by quantreg, which requires one, with its own messages ("Intercept required in common tau testing" with two regressors, "argument is of length zero" with one), as before; a refusal of its own would say why.

0.17.0: the model context, part 2 (increments 8–11) (done)

Moved from 0.16.0 on 2026-10-26: the last four steps of the model context, and the defects found with it that those steps, or a pull request of their own, settle. Among those are N3, White's streaming test on collinear terms (decision 22 of the design), and the threshold of the streaming tests on squared residuals that do not vary. Done in #164 (N3 and the threshold), in #165 and #167 (step 8), #168 (step 9), #169 (step 10) and #171 (step 11). The release also fixes CRAN's additional check of 0.16.0 with Intel MKL (#172, under 0.11.x). No export, argument or default changes; hardhat joins the suggested packages. What the steps found and did not settle moved to 0.18.0 at the release (Left from steps 8 to 10 of the model context).

  • The model context, part 2. Steps 8 to 11 of the plan on the site, docs/design/model-context.md, each one pull request from main, checked by scripts/model-context-identity.R and pinned by tests/testthat/test-model-context.R, as steps 1 to 7 were (the model context, under 0.16.0). Done in 0.17.0: every step of the plan is done.
    • 8. The resampling tests and bootstrapTestStatistic(), replicates from the stored design: the observed statistic and the replicates of performWhiteTestBootstrap() move together, and rwhite_test_rebuilt_design() goes; bootstrapTestStatistic() and performWhiteTestRobust(bootstrap = TRUE) no longer need to refuse a fit whose frame cannot be rebuilt from data. Closes B12 for replicates, N8, and N17 for the replicates of the White bootstrap. Done in 0.17.0 (#165, and its review in #167): the White and wild bootstraps, bootstrapTestStatistic() and the robust White and Breusch-Pagan tests build the context, and each replicate is refitted by least squares on the stored design, drawing the random numbers it drew before, in the same order. Closes too B5, B14, B15, L1, L2 and N7 for these (cut(x, quantile(x)), refused or stopped, gives the value of the factor computed beforehand), and pairs resampling holds the design of the fit. A Gaussian glm is bootstrapped as the lm it is; a gam with smooth terms is refused by the White bootstrap, whose refit stopped on it, and by bootstrapTestStatistic(), which refuses every gam, as before. An edited fit (M8) and a model = FALSE fit whose data are gone given a shuffled tibble ("frame"), from which the White and wild bootstraps computed a value, are refused. Its review: pairs resampling keeps the contrasts of the design and codes a factor or a character regressor a replicate leaves a value out of as a refit of the formula does; a fitWLS() fit of a gam with smooth terms, whose terms do not rebuild its design, is refused (M8); and bootstrapTestStatistic() refuses a multivariate lm with M7.
    • 9. na.exclude, the second step of 0.17.0 (decision 16). Closes B13. Done in 0.17.0 (#168): a fit with na.action = na.exclude drops the rows na.omit drops, and pads residuals(), fitted(), weights() and hatvalues() with NA at them. The checks of the model read one residual per observation of the fit, and every test built on the context reads the vectors of the fit without the padding, so such a fit gives what the same fit with na.omit gives on the same data: the same statistic, p-value, refusal and warnings, and for the resampling tests the same draws. The tests and the runners refused it ("Model residuals must be finite"), RESET stopped on it ("NA/NaN/Inf in 'x'"), and the panel tests and the scatter diagnostic read its padded residuals against the rows of data by position, which gave NA, or a value computed with the NA, and refused data in another order and an unbalanced panel of the rows of data; that reading goes. An output with one value per row is padded as naresid() pads it: the estimated variances fitWLS() attaches to its fit. rvalidate_against_reference() gives lmtest and car the same fit read as na.omit, on whose padded weights lmtest stopped. From the review of the step: an mgcv::gam() fit with na.exclude, whose design model.matrix.gam() pads with NA rows, which the tests refused for parts of different lengths, is read through the design of its twin; and the functions outside the context that the checks now let such a fit reach read it without the padding too (below). The twin rule is decision 32 of the design, decided by the author on 2026-10-28.
    • 10. The runners and the clean-up: a grouped run of a fitWLS() fit refused (decision 10), workflows with data; prepare_model_data_for_test(), rdata_or_model_frame(), rcheck_data_rows() and rcheck_rows_after_cleaning() go, which no test calls since step 8. The assertions of tests/testthat/test-data-alignment-guard.R that call prepare_model_data_for_test() (the tibble renumbered by the rows it dropped) and rcheck_data_rows() (no column that can be compared) exercise these dead internals, and go with them. Closes B11, N5. With the workflows with data (N5): recommendRemediationStrategies() and generateHeteroRecommendations(), and generics::augment() of the results of the runner, should match the rows of data to the observations of the fit, as the tests do, where since step 9 they refuse data of another number of rows than the observations (or, for augment(), than the fitted values) with a message that names both. Done in 0.17.0 (#169): runHeteroTests() refuses a grouped run of a fitWLS() fit, whose refit within each group left out the estimated weights, with the class of the model context, before any group is refitted; a grouped run of a weighted lm is unchanged. The runners map the data a workflow was fitted to onto the variables of its engine by the workflow's own preprocessing (hardhat::forge(), a suggested package), and the outcome of a parsnip fit of fit_xy() onto ..y, confirm the rows by the alignment of the context, and test the model frame of the engine's fit, the value without data; data in another order than the workflow was fitted in, and data that cannot be mapped, are refused with a message that says why. The recommendations and augment() match the rows of data through the alignment of the context: named data in any order, numbered data in the order of the fit, and, for the recommendations, the full data of a fit that dropped rows, give the value of the rows of the fit in its order; what the alignment refuses is refused. The four internals go; the assertions on them that a public function still makes are made on that function. The two choices the first version left open were decided by the author on its review (decisions 34 and 35 of the design): augment() binds the rows of data as broom does, in their order, the rows the fit used on a fit with na.omit and every row with NA at the dropped ones on a fit with na.exclude; and a preprocessing that transforms the outcome maps it to ..y. From the review: augment() maps the data of a workflow or of a fit_xy() fit as the runners do; the refusals of data that lack the variables of the model name the function and say what to pass; a recipe step that drops rows at the fit only (step_naomit()) is matched when its rows can be confirmed, and refused with a message that names such a step otherwise (step_filter()); the case weights of a workflow are compared, as the weights of a fit are for the tests; the report profiles the data as given, and keeps the groups of grouped data; and the refusals of a model the context refuses by kind, and of data that lack the variables of the model, are announced in bold. From its second review: augment() traces the rows a workflow's preprocessing maps to the rows of data, and refuses the data of a preprocessing that reorders them (step_arrange(), whose rows it bound the values of other rows); a step skipped on new data that changed the outcome (step_log(y, skip = TRUE)) is named in the refusal; the case weights are compared on the rows a recipe keeps of new data; the results keep what augment() needs of the workflow, not the workflow (2 KB a result, where the workflow added 1,272 KB to three results on 5,000 rows); and the identity harness compares the attributes of the results of the runners and checks each value of augment() on the tidy block row by row. From its third review: the data of a fit_xy() fit with case weights, whose column the fit does not record, are refused; a refusal names only the skipped steps without which the rows are not confirmed; and the refusal by augment() of a step that makes a row from other rows, whose values 0.16.0 bound right, is announced. Decided by the author on its fourth review (decision 36): the blueprint is kept as the workflow keeps it, a recipe step with the environment it was written in, so that a saved result carries what that environment holds, as saving the workflow does (51,245 KB for a recipe written in a function whose frame held 50 MB, where main gave 1,894 KB); the third review's rewriting of the expressions, which changed what some of them mean, is gone.
    • 11. MASS::rlm() refused (decision 4). Closes R6, N10. Settle with it whether a penalised mgcv::gam() with smooth terms, a Gaussian glm by class, is an input the tests justify: its residuals are not orthogonal to its design, and the null distributions assume least squares. The five tests of step 1, the six of step 3, the five of step 4 and the three streaming tests of step 7 accept it as before; the four of step 2 refuse it until this step settles it for every test together. In two parts, both done in 0.17.0 (#171):
      • MASS::rlm() refused. Closes R6, N10. Done in 0.17.0 (#171): the model context refuses such a fit, an M-estimate whose residuals are not least-squares residuals, with the class of its refusals, before data is matched (rrefuse_rlm()), so every test, its registered name, test_factory entry and runner refuses it, the runners keeping the refusal final, and the message names the lm() fit of the formula to test instead. 37 tests tested it as an lm, and the studentized Breusch-Pagan statistic tested the least-squares refit of its design (9.04 where Koenker's test gave 8.74); fitWLS(), which fits its variance function to the residuals of the fit, refuses it too, and the help page of fitRobust(), which returns such a fit, says so. From its review: a grouped run refuses it before any group is refitted, where with MASS attached it refitted each group as an rlm and returned its values; autoCompareRemediations() refuses it; and the lm() call the message suggests keeps the data, weights, subset, na.action, offset and contrasts of the fit. From its second review, the call of a fitRobust() result names the formula and the data the caller gave, where it named the variables of fitRobust(), so that the suggested call works and update() refits it.
      • The penalised mgcv::gam() with smooth terms (decision 23): which tests take it. Decided by the author on 2026-10-29 on the survey of the first part (decisions 37 and 38 of the design of the model context). Such a fit is tested on its stored design, the basis of its smooths, by every test that reads only its residuals, fitted values, columns of data or the columns of its design as auxiliary regressors, and the three streaming tests read that basis too, where they read the variables of the formula (the Breusch-Pagan test 23.995 on 10, where it gave 11.185 on 2, Koenker's 18.313 on 10 for 8.537 on 2, White's 46.05 on 60 for 10.61 on 5). Every test that fits it again by least squares, or whose null distribution is derived for least-squares residuals of its design, refuses it with the class of the model context, final in the runners: RESET, Goldfeld-Quandt, the quantile regression test and the studentized Breusch-Pagan statistic, as before, and now the White and wild bootstraps, bootstrapTestStatistic() and the robust bootstraps, the variance form test and fitWLS(), which refitted it without its penalty, and the Harrison-McCabe and Szroeter tests; the runner no longer puts White's test under the name wild_bootstrap. A gam that is the least-squares fit of its design, without smooth terms or with fixed ones, is taken by every test as the lm() of that design, the refitting and resampling tests included, where a fixed smooth was refused as a penalised one and bootstrapTestStatistic() and the wild bootstrap refused or stopped on every gam. From the second review: the check of a least-squares gam is drawn on the residuals of the refit of its design, plus the rounding of the response and the offset, not on the spread of the response, which an offset or a trend inflates, so that how the model is written no longer decides it; a gam without a penalised term that fails it is not called penalised; and a refusal of the context in a group stops a grouped run (decision 6). Done in 0.17.0 (#171).
  • Found by the model-eligibility work (the Supported models page), recorded, not fixed, as they change what a test computes rather than which models it accepts. Those below are settled by the steps above or with them; the others are under 0.16.0 and 0.18.0. Done in 0.17.0, but for the two found by the reviews of step 10 on augment() of a workflow, which are not settled and moved to 0.18.0 at the release (Left from steps 8 to 10 of the model context):
    • performWhiteTestStreaming() stops with "Auxiliary regression became rank deficient" when terms of its auxiliary regression are collinear, as the squares of dummy variables are, on any model with a factor, where performWhiteTest() drops them. Drop the collinear terms from the accumulated cross-products, as the exact test does. Changes a value (an error becomes one). Done in the pull request of N3 (#164): the three streaming tests reduce each chunk of the auxiliary design of the regressors centred at their means by a QR decomposition stacked on the factor of the chunks before it, rather than accumulating its cross-products, and decide its rank as performWhiteTest() does, qr() of the intercept and the centred auxiliary design at its tolerance, in the order of the columns, on that factor, which has the norms and inner products of those columns. White's streaming test drops the collinear terms and gives the degrees of freedom and the value of performWhiteTest(): 17.83 on 8 on lm(y ~ x1 + group), where it stopped. The pivoted Cholesky decomposition of the centred cross-products (#151), used on the accepted path as well, counted one term too many (31 for 30) on 30 subjects of two observations and one of three, from the rounding of the centring of cross-products whose columns are far from their means, and 54 for 60 on the basis of a smooth gam, where the factor gives performWhiteTest()'s 60 and its value. The cross-products of a regressor 100 standard deviations from zero no longer look collinear (White's test), nor those of one 1,000 standard deviations from zero beside the intercept (the Breusch-Pagan and Koenker tests); the values of the three that change, change in their last bits, or to the value of the exact test where the normal equations lost more digits. Found with it: in more than one chunk, on a regressor a beside a_sq, whose square White's test names a_sq too, the columns of each chunk were matched by name and White's streaming test gave 27.76 (chunks of 7) where performWhiteTest() gives 27.64; every chunk has the columns of the stored design, taken in their order. The exact White, Breusch-Pagan and Koenker tests, and runHeteroTests() with its default chunk_threshold_mb = 100, switched to the streaming ones above 100 MB, so that such a model got a fallback under the name white on large data. Done in 0.16.0, decided by the author on 2026-10-22: the streaming tests run only when asked for, by calling them or by giving runHeteroTests() a finite chunk_threshold_mb, whose default is Inf; the three stay exported, not deprecated. The rest is N3 of the design of the model context, a pull request of its own (decision 22).
    • performWhiteTestStreaming() on an mgcv::gam() fit with smooth terms tests the design of terms(gam), the parametric terms with the variables of the smooths as linear terms, not the design of the fit: on gam(y ~ s(x1) + x2) it gives 33.31 on 5 degrees of freedom where performWhiteTest() gives 83.29 on 59. Found by the review of N2. The step of the model context that builds the streaming tests on the stored design (step 7), or the one that settles whether the tests take a penalised gam (step 11), settles it. Changes a value, or refuses one. Step 7 keeps the design of the variables of the formula, built from the model frame, so that the values are those of before, and leaves the question to step 11, as decision 23 provides for every test outside step 2 (the review of step 7 agreed): on the stored design the Breusch-Pagan and Koenker streaming tests would give the values of the exact tests, and White's would refuse the collinear squares of the basis of the smooths (N3). Since the pull request of N3 White's would give the value of performWhiteTest() there too: on gam(y ~ s(x1) + x2) in the design of contract_data(), 46.05 on 60 degrees of freedom on the basis, as performWhiteTest() gives, where on the variables of the formula, which it keeps, it gives 10.61 on 5, as before (to the last bits). Done in step 11 (#171, decision 37): the three streaming tests read the basis, chunk by chunk, and give the values of the exact tests, 46.05 on 60 here.
    • The three streaming tests refuse squared residuals that do not vary, "requires variation in squared residuals", by an absolute threshold: chunked_regression_r2() compares their total sum of squares with .Machine$double.eps, whatever their scale. Residuals of about 1e-5 are refused: with set.seed(11); n <- 120; d <- data.frame(x1 = runif(n, 0.5, 2), x2 = rnorm(n)); d$y <- 1e-5 * rnorm(n, sd = d$x1); m <- lm(y ~ x1 + x2, data = d), performWhiteTestStreaming(m, d), performBPTestStreaming(m, d) and performKoenkerTestStreaming(m, d) stop, where performWhiteTest() gives 12.787 on 5 degrees of freedom, performBPTest() 12.997 and performKoenkerTest() 10.289; the same data times 1e5 stream to 12.787. Found in step 7 of the model context, which leaves it as it was. Compare the total with the scale of the squared residuals. Errors become values. Done with N3 (#164): the exact tests decide constancy without a scale, performKoenkerTest() refusing squared residuals that are all equal and the R-squared of lm() not depending on the scale of the response; the streaming tests take the squared residuals for constant when their total sum of squares about their mean is at most .Machine$double.eps of their sum of squares, when they agree to about eight digits, and give 12.787, 12.997 and 10.289 on those data. Squared residuals equal but for rounding (residuals of +1 and -1 orthogonal to the regressors) are refused at any scale and in chunks of any size, where in chunks of 40 or more they gave a value from the rounding at a scale of 1 (Koenker's 40, the number of observations) and refused it at others.
    • runHeteroTests(<workflow>, data) refuses the data the workflow was fitted to, because the engine's fit names its response ..y; without data it uses the model frame of that fit (0.16.0), and the refusal says to pass data = NULL. Map the columns instead. An error becomes a value. Done in step 10 (#169, above): the data are mapped by the workflow's preprocessing, and give the value without data.
    • A workflow whose preprocessing transforms the outcome, as a recipe with step_log() of the outcome, maps the outcome of data to its transformed values, which are the response ..y of the engine's fit; the first version of step 10 refused such data, saying that the preprocessing transforms the outcome. Decide whether the transformed outcome of the preprocessing is taken as the response, which gives the value without data, or the outcome column of data is required to be ..y, which refuses them. Decided by the author on the review of step 10 (decision 35): the transformed outcome is taken, for a recipe and for a formula preprocessor log(y) ~ x + z alike, and the value is that without data. Done in step 10 (#169).
    • MASS::rlm(), decision 4 of the model-context design: 37 tests accept it as an lm and test its M-estimation residuals as least-squares ones; the studentized Breusch-Pagan statistic of performStudentizedBPTest() and performBPTestRobust() refits its design by least squares and tests the least-squares fit instead (9.04 against Koenker's 8.74 on the residuals of the M-estimate, in the design of the Supported models page); and performQuantileRegressionTest() refuses it as a weighted fit, because its case weights are all 1. Keep accepting it, or refuse it. Deferred on 2026-10-17 to the work on the model context; accepted as before until then. Done in step 11 (#171, above): refused by every test, with the class of the model context, the runners keeping the refusal final.
  • Found by the review of the Gaussian glm work (#136), recorded, not fixed (the other is under 0.16.0), and settled by step 8, which closes N8:
    • bootstrapTestStatistic() refuses every glm, because it refits each replicate by least squares, which would drop a family and a link. A Gaussian glm with the identity link is a least-squares fit (the Supported models page), so the same justification lets it accept one and bootstrap the lm. A refusal becomes a value. Done in step 8 (#165): its replicates are those of the weighted lm, to 1e-8, with the same p-value; another glm, a survey::svyglm() fit and an mgcv::gam() fit are refused, as before.
  • Found by step 8 of the model context, recorded, not fixed, as each changes what a call returns. Done in 0.17.0 for the wild bootstrap and for the fitWLS() fit of a penalised gam; the fitWLS() fit of a gam whose smooths are all fixed, and the tolerance of the replicates of bootstrapTestStatistic(), are not settled and moved to 0.18.0 at the release (Left from steps 8 to 10 of the model context):
    • performWildBootstrapTest() stops on every mgcv::gam() fit, with or without smooth terms ("lm object does not have a proper 'qr' component"): it reads the leverages with hatvalues(), which a gam does not give. Before and after step 8 alike. Read the leverages of the stored design, or refuse the gam with a message of its own. An error becomes a value, or a message. Done in step 11 (#171): the wild bootstrap reads the leverages of a gam from its design, so a gam that is the least-squares fit of its design gives the value of the lm() of it, and refuses a penalised gam with a message of its own, as a fit each replicate fits again by least squares.
    • fitWLS() of an mgcv::gam() fit with smooth terms returns an lm whose terms do not rebuild its design: model.matrix() gives a column for each variable of the formula, for the coefficients of the basis of the smooths. The tests built on the model context stopped on it with "(subscript) logical subscript too long"; before step 8 the White and wild bootstraps returned values on the design of the variables. Since the review of step 8 the context refuses it, as a fit whose design does not reproduce its residuals (M8). Still to settle with step 11: refuse such a fit in fitWLS() itself, or keep its basis there, so that it can be tested. Done in step 11 (#171) for a penalised gam, which fitWLS() refuses itself, as it would refit it without its penalty. A gam whose smooths are all fixed, the least-squares fit of its design, is refitted, and its lm is refused by the tests as since step 8 (moved to 0.18.0).
  • Found by step 9 of the model context and its review: functions that are not built on the context and read the padded vectors of a fit with na.action = na.exclude once the checks of the model let it through, or keep one value per observation where the rule of step 9 pads them (decision 32 of the design). Done in 0.17.0, but for the positions of the influential observations, which are not settled and moved to 0.18.0 at the release (Left from steps 8 to 10 of the model context).
    • analyzeMLResiduals() returned rmse_reduction = NA on such a fit, and autoCompareRemediations() an NA RMSE for the OLS and WLS fits (1.32 and 1.33 on the same fit with na.omit): each took the root mean square of resid(), padded with NA. Done in step 9 (#168): the residuals of the observations of the fit, as the tests read them.
    • generateHeteroRecommendations() stopped with "incompatible dimensions" (identifyVariancePatterns() correlated the padded absolute residuals with the columns of the model frame), and plotBubbleVariance(), and so plotDiagnosticSuite(), its deprecated name and generateDiagnosticReport(), with "replacement has 120 rows, data has 117"; plotResidualsFitted() with and without enhanced, plotSpreadLevel(), plotResidualDensity(), plotResidualQQ(), plotBeforeAfter() and plotResidualsFittedEnhanced() drew the NA, with a warning of ggplot2, "Removed 3 rows containing non-finite values", that the same fit with na.omit does not give. Done in step 9 (#168): they read the residuals and fitted values of the observations, and draw what they draw on the same fit with na.omit.
    • generics::augment() of the results of the runner stopped with "replacement has 120 rows, data has 117" without data or given the rows of the fit. Done in step 9 (#168): it pads those rows with a row of NA at each row the fit dropped, the result of the same fit with na.omit padded; given the data of the fit, it binds the padded columns to its rows, as before; and data of another number of rows are refused with a message that says what it binds, for a fit with na.omit too, where they stopped with the same error, or, when their number of rows was a multiple of the number of fitted values, had them recycled over the rows, bound to other observations (a new refusal on any fit, announced in bold). Open: the same fit with na.omit given the full data of the fit is refused (its columns have one value per observation), where the fit with na.exclude binds them; binding them by the row names of the observations would give that fit the rows of the other, a value where it is refused. Decided by the author on the review of step 10 (decision 34), and done there (#169): the rows of data are matched to the fit as the tests match them and bound as broom binds them, in the order of data: on a fit with na.omit the rows the fit used, 117 of the 120 of its full data; on a fit with na.exclude every row, with NA at the rows it dropped, the twin padded; and the full data shuffled under their own row names, which a fit with na.exclude bound by position, get each row its own fitted value.
    • recommendRemediationStrategies() correlated the residuals with the columns of data row by row: given the full data of a fit that dropped rows, it stopped with "incompatible dimensions" on a fit with na.omit, and read the padded residuals of a fit with na.exclude by position. Done in step 9 (#168): it refuses data of another number of rows than the observations of the fit, naming both counts. Matching the rows of data to the observations, as the tests do, is left to step 10 (above), done there (#169): the full data give the value of the rows of the fit.
    • On an mgcv::gam() fit with na.exclude, whose model.matrix() pads the design with NA rows, performVIFDiagnostic() gave NA, and cooks.distance() of the gam recycled the padded residuals against the leverages, with a warning, so performInfluenceDiagnostics() and runDiagnostics()$influence gave other distances. Done in step 9 (#168): they read the fit as the same fit with na.omit, the distances padded as naresid() pads them (on a weighted lm, whose cooks.distance() left the names of the padded positions NA, they are named as the dropped rows, as on an unweighted one).
    • The record of the variance function that fitWLS() attaches to its fit stays unpadded on a fit with na.exclude, one row per observation of the fit (decision 33, decided by the author on 2026-10-28); only the estimated variances, "variance_model", are padded. Done in step 9 (#168), as decided.
  • Defects found during the 0.12.0 and 0.13.0 work, left for 0.16.0 so that 0.14.0 changes no value. Each fix that changes a value is announced in bold in NEWS.md. Those below are settled by steps 8 and 10; the others are under 0.16.0 and 0.18.0. Done in 0.17.0.
    • The single tests and performWhiteTestBootstrap() match a reordered tibble or data.table to the fit by position, through prepare_model_data_for_test(). Since 0.15.0 they refuse such data instead of returning wrong values: rcheck_data_rows(), called by rdata_or_model_frame(), compares every column of the model frame, on the rows the row names pick, with the model frame: the columns data holds under their own names value by value, and the others (log(y), log(x), poly(), ns(), offset(o), (weights), (offset)) computed from data through the predvars of the terms and the weights and offset expressions of the call. A term that depends on the rows it is computed from (I(x - mean(x)), I(x > median(x)), rank(x)), found by computing it again on half the rows, is not compared when it differs, and neither is a column that names a variable data lacks or whose computation fails; the compared columns must then leave no two rows of the fit that tie on them and differ in such a column computed from variables of data, and at least one column must be compared. The tests stop when a compared column differs, when such ties remain, when row names repeat, or when a fit with model = FALSE rebuilds a frame whose response or fitted values are not the fit's; runHeteroTests() and runDiagnostics() do not fall back on another test for it. The model context replaces this guard, so that named data in any order, and numbered data in fit order, give the value on the data of the fit; numbered data in another order stay refused (decision 3 of the design). Changes a value for such data. Since step 10 of the model context the guard is only its alignment step (rdata_fit_rows(), rcheck_rows_at()): prepare_model_data_for_test(), rdata_or_model_frame() and rcheck_data_rows() are gone. Its cost on a valid call grows with the columns it computes again: at 100,000 rows it takes about 5 ms on lm(y ~ x1 + x2 + g) and about 43 ms on a weighted fit with log() and poly() terms, against 50 to 70 ms for the Glejser and Bartlett tests and about 200 ms for Koenker's on main. The review of 0.15.0 measured it at about a third of the Glejser (100 ms) and Bartlett (120 ms) calls at that size, before factors were compared by their codes; the model context, which reads the design from the fit, does not need it. Done in 0.17.0: the replicates of the White bootstrap in step 8 (#165), and the guard reduced to its alignment step in step 10 (#169). Not covered by the guard:
      • fitWLS(data, var_formula) and performVarianceFormTest() with var_formula or against read the variables of the second formula through rvariance_frame(), which matches by name and, when no name matches and the counts agree, by position, unchecked (a reordered tibble changes their value). Since the sixth step of the model context they read them at the rows the alignment confirmed, and rvariance_frame() is gone.
      • The tests that read nothing from data (performSpearmanTest(), performHarveyTest(), performArchLMTest(), performMcLeodLiTest(), performCookWeisbergTest() without var_formula, performNCVTest(), performSpreadLevelTest(), performDavidianCarrollTest(), performVarianceFormTest() without a second formula, performRESETTest()) accept reordered data and return the value of the fit. Since the sixth step of the model context they match data as every other test does (decision 31 of the design), and refuse such data.
      • The columns a test reads besides the model (group, order_by, variable, id, time) are not compared. When every column of the model frame is compared, two rows that tie on all of them (response, regressors, weights, offset) can pass for each other, as with a binary or integer response; they then have the same design row, weight, offset and residual, so exchanging them moves only those other columns between identical observations. The review of the guard found that swapping such rows of an unweighted fit with no offset left the Bartlett, Glejser, Szroeter and GQ statistics unchanged.
      • A column computed from a vector of the workspace (weights = wvec, offset = ovec, log(x + k)) is not compared; no test reads it from data, so it needs nothing more.
      • The guard refuses data it cannot confirm, even the data of the fit: (i) when no column of the model frame can be compared, as with lm(d$y ~ d$x); (ii) when the compared columns tie on rows of the fit that differ in a term that depends on the rows or in a term of variables of data that cannot be computed, as with a discrete response and a row-dependent regressor on the rows an na.omit fit kept (lm(score ~ g + I(x > median(x)) + z2)), or y01 ~ myf(x) after rm(myf) and ns() after detach("package:splines"). The message names the columns; pass the full data the model was fitted on, with the rows it dropped, or the variables as columns of data.
      • A term that depends on the rows is recognised by computing it again on the odd and even rows and on each half, so every row is left out of one of them; one that gives the same values on all four by chance is compared, and refused on the rows a subset = or na.omit fit kept.
      • Data that reproduce every column of the model frame exactly but are another data set, with another extra column the test reads (g in T1b of the review), give a wrong value without an error, as in 0.14.0.
      • performScatterDiagnostic() refuses a tibble in the order of the fit of a model fitted to data with character row names, which it cannot match by name. Since the fifth step of the model context it takes such data in the order of the fit.
      • cut(x, quantile(x)) in the formula makes Koenker's test stop with "factor has new levels", on main and with the guard alike. Since the first step of the model context the five tests built on it read the design of the fit and return a value; the others follow their steps.
      • The second review of the guard measured it at about 20 to 40% of a cheap test (Bartlett, Glejser) at 100,000 rows.
      • A model = FALSE fit whose data changed after the fit is refused by the tests that read data, with or without it, but the tests that read only the model rebuild its frame too: performRESETTest() takes its response from that frame and stays wrong, as in 0.14.0. Since the second step of the model context RESET refuses it, and since the sixth the other tests computed from the model alone and fitWLS() do.
      • bootstrapTestStatistic() and rbootstrap_test_statistic() keep their own check (0.13.0), and the panel tests and performScatterDiagnostic() refuse data with row names of their own in another order instead of matching it by name. Since the fifth step of the model context the panel tests and the scatter diagnostic match it by name, and since the eighth bootstrapTestStatistic() matches it as the tests do, its refit of the formula on data replaced by the stored design.
      • The (weights) column is compared with the weights expression of the call computed on data. If the fitWLS() defect above is fixed by writing the estimated weights into (weights) (decision 11 of the model-context design) while the call still says weights = w, every test given data on a fitWLS() fit of a weighted model would be refused; the model context, which takes the weights from the fit, avoids it, and this guard must then skip that column for such fits.
    • A multivariate lm, cbind(y, y2) ~ ..., fails with a coercion error instead of a clear refusal. No value changes. Since the first step of the model context the five tests built on it refuse it with "needs a model with one response"; the others follow their steps. Done in step 8 (#165, and its review in #167), whose resampling tests and bootstrapTestStatistic() were the last to meet the coercion error; since step 11 (#171) a grouped run stops with the refusal too.

0.18.0: internals, dependencies and the defect backlog

Moved from 0.16.0 on 2026-10-26, with no API change: the internals and dependencies planned for it, and the defects recorded before and during it that the model context does not settle. Each fix that changes a value is announced in bold in NEWS.md.

  • The reverse-dependency job of R-CMD-check.yml has never passed. On every weekly run since at least 2026-10-05, and on the manual runs on the 0.16.0 release branch, it stopped in setup-r-dependencies with "Could not solve package dependencies" (pkgdepends), before revdepcheck::revdep_check() ran. It is not part of the release gate (release.yml). Done in #166: the job asked for any::revdepcheck, and revdepcheck is not on CRAN; it is installed from GitHub, r-lib/revdepcheck. The package has had no reverse dependencies on CRAN so far, so the job checks none, but it runs: on the manual run of the 0.17.0 release branch it passed, with no reverse dependency to check.
  • The DOI of Szroeter (1978) on the help page of performSzroeterTest(). The page gave doi:10.2307/1913833, which Crossref resolves to Dagenais (1978), "The Computation of FIML Estimates as Iterative Generalized Least Squares Estimates…", in the same issue of Econometrica. Szroeter's paper is doi:10.2307/1913831 (Econometrica 46(6), 1311–1327). Fixed in the roxygen (R/additional_tests.R), the help page and its page on the site. In the same way, inst/CITATION named Fligner's co-author "Thomas J. Killeen"; Crossref, and the article in JASA 71, give Timothy J. Killeen. The help pages give only the initials. Found by the R Journal article's additions (heteroTests-paper #6) and their review.
  • man/ is not what roxygen writes from R/. On main after 0.17.0 (a3709bc), devtools::document() with roxygen2 7.3.3, the version of RoxygenNote, changes 36 files of man/:

    • It deletes ht_logging.Rd and rbootstrap_test_statistic.Rd, and writes 15 pages that man/ lacks. Among them are bootstrapTestStatistic.Rd, for the @rdname bootstrapTestStatistic of R/bootstrap_methods.R, and heteroTests_ggplot.Rd, a topic with no page today.
    • It rewrites 19 more. Some of the changes are in the text: ht_log.Rd still has the wording of September 2025, which R/utils.R has since replaced. Others are in the form: the usage is wrapped one argument per line, \link{} replaces \code{\link{}}, and \code{} replaces the \file{} of paths.

    So some pages were edited by hand, or written by another roxygen setup, and a help page can lag its source. That includes the pages of the CRAN tarball, which R CMD build takes from man/. Find which, make man/ the output of devtools::document(), review the help pages that change, and add a CI step that fails when document() changes man/. Found while fixing the DOI above, which was fixed in the roxygen and in the help page alone. Done in #180: the pages had been edited by hand, or written by hand under a roxygen header, as ht_logging.Rd was in #7, and document() had not been run on them since; neither the roxygen2 version nor the collation locale makes a difference. The edits the roxygen lacked were carried into R/ (the .recover argument of safe_lm(), the arguments of print.htest_enhanced(), the model of performQuantileRegressionTest()), with the faults of the roxygen markdown they hid: `r ~ z` evaluated as inline R code, \% written as \\%, which drops the rest of the line, and a link to parallel::mclapply() that roxygen resolves to mcdummies on Windows. One sentence was dropped as no longer true ("Neither strategy accepts a glm", on the page of bootstrapTestStatistic()), and the eight helpers documented on generateHeteroRecommendations.Rd name it as their page. The block that opens R/ggplot_extensions.R documents no object and is @noRd, so heteroTests_ggplot gets no page. The 86 pages roxygen writes are now what document() writes, and a step of run-checks.yml fails when roxygen2::roxygenise(), with the roxygen2 of RoxygenNote, changes man/ or NAMESPACE; the 52 it does not write are the next item. - [ ] 52 help pages and NAMESPACE are written by hand. roxygen2 does not overwrite a file without its "Generated by roxygen2" line, and 52 pages of man/ have none, among them those of runHeteroTests(), runDiagnostics(), performWhiteTest(), performBPTest(), performKoenkerTest() and most of the tests, and generateHeteroRecommendations.Rd. 45 of them are pages of a roxygen block in R/, which users do not read and the CI step above cannot see; the other 7 (test_results, diagnostic_data, hetero_test_suite, simulate_hetero and the methods of HeteroDiagnostic) have no block. The page and the block have been kept up side by side. Given the roxygen line, the 45 pages are rewritten by +3,332 / -1,451 lines (git diff --numstat). Split into sentences, their rendered text holds 550 to 715 sentences of the page that the block lacks (the Supported models sections, the DOIs, aliases such as generate_benchmark_report and the glance() and augment() methods) and 570 to 670 of the block that the page lacks (references, see-also lists, examples), by the two ways #180 and its review matched them. Merge each page into its block, delete the page and let roxygen write it; write the 7 as blocks. The blocks of performBPTest(), performKoenkerTest() and performWhiteTest() have 28 lines with \%, which markdown writes as \\%; two blocks have no @name (R/error_handling.R, R/intelligent_recommendations.R). generateHeteroRecommendations.Rd documents nine functions, eight of whose blocks name it with @rdname: written by roxygen, it would take its \name and title from the first of them, analyseDatasetCharacteristics(), and one data argument, that of generateHeteroRecommendations(), while the page's data, updated for 0.17.0, is in no block. NAMESPACE has no roxygen line either: written by roxygen it would lose 59 exports whose blocks have no @export (runHeteroTests(), performBPTest(), the sigma_*() functions among them), the importFrom() of stats, MASS, R6 and utils and importFrom(ggplot2, scale_fill_manual), and gain a, Register and heteroscedasticity and the registration of print.htest_enhanced(), which NAMESPACE lacks today (a method for a class no function of the package returns). A first run with no NAMESPACE also exports the two autoplot() methods as functions, since the package then loads without importFrom(ggplot2, autoplot); a second run registers them again. Found by #180. - [ ] The site's reference pages do not render \tabular. tools/build-docs.R has no case for it, so render() renders its children: the column format becomes text, \tab is dropped and \cr becomes a line break. The tables of four pages come out as lines of cells separated by two spaces, after the format as a stray token: docs/reference/performBPTest.md, performKoenkerTest.md and performWhiteTest.md (rrrrrl, lines 42, 41 and 67), the rejection rates with fixed effects for small groups, from man/performBPTest.Rd, performKoenkerTest.Rd and performWhiteTest.Rd, written by hand; and performWildBootstrapTest.md (line 112, "0.058.llrrrr", the format glued to the sentence before the table), the size of the wild bootstrap by errors and multiplier, from the markdown table of the roxygen of performWildBootstrapTest() (R/modern_diagnostics.R). R's help renders them. build-docs.R would need a "\\tabular" case that writes a pipe table: the alignment of each column from the format (l, r and c as :---, ---: and :---:), the rows split at \cr and the cells at \tab, each cell rendered inline on one line, with | escaped, the first row as the header, which each of the four tables has, and a blank line before and after, since the table of performWildBootstrapTest.Rd starts on the line of the sentence before it. Found by #182; recorded, not fixed. No value changes. - [ ] Dependencies. - Move curl to Suggests: only downloadTeachingData() uses it. - R6 goes if TestFactory leaves the API, since it is the only user. - Suggests shrinks with the companion split. - Settle whether digest belongs in Imports or Suggests, depending on whether caching stays exported. - [ ] Pass R CMD check with _R_CHECK_DEPENDS_ONLY_=true. With 39 packages in Suggests, every test, example and vignette that uses one must degrade gracefully. Audit the skip_if_not_installed() coverage; 20 of the 69 test files currently use it. - [ ] The R floor. DESCRIPTION claims R >= 4.1, but CI tests only devel, release and oldrel-1. Either add R 4.1 to the matrix or raise the floor to what is tested. - [ ] Replace non-ASCII characters in R/ with escapes (89 lines across 27 files). - [ ] The memory of a cache key. rcache_hash() (R/cache.R) serializes the model frame, the design, the vectors of the fit and the data into one raw vector on every call of cachedTest() and runHeteroTests(), about twice their size, which a wide design makes large, and frees it at once. Hash them part by part, or stream the serialization into the hash, so that the temporary allocation stays small (found by the review of #144). No value changes. - [ ] A runner call changes how the workflow it is given serializes. The names of the residuals and fitted.values of a workflow's engine fit are ALTREP deferred string conversions of the row numbers, which expand when they are read, and the runners read them: after runHeteroTests() of a workflow fitted to 120 observations they take 2,236 bytes where they took 1,209. No value of the workflow changes, but a cache that hashes objects, such as that of targets, sees a workflow changed by the call. Found by the review of #169, which saw it on main as well; recorded, not fixed. Read the names without expanding them, or work on a copy of the vectors. - [ ] Replicates lost without a warning. A replicate of bootstrapTestStatistic() whose refit fails, or, under pairs resampling, whose design cannot be built on the rows it drew (rpairs_design(), since the review of step 8 of the model context), is left out silently: only effective_samples shows it, as for a failed refit before. The replicates of a fitWLS() fit whose variance function cannot be fitted are left out with a warning that gives their number. Consider warning whenever replicates are lost (found by the review of #167). No value changes. - [ ] Split R/validation.R (1,441 lines) into cache, result-type, assumption-checking and requirement-dispatch units, and factor out the repeated boilerplate: scalar validators, model and data preparation, and intercept stripping. - [ ] Retire TODO.md, SUGGESTIONS.md and cran.md. The open items in the first two all concern setup.sh and bootstrapping renv, and cran.md is a generic guide. With CRAN now the install route, decide whether setup.sh, renv.lock and the Dockerfile remain supported. If they do, complete renv.lock against Imports/Suggests so the Docker build uses it instead of install_local(). If they do not, remove them. - [ ] Keep the CRAN test run short. Long simulations run under skip_on_cran(), and the full versions run on CI. - [x] runHeteroTestsParallel() names its results "model". It returns what the registered functions return, so White's test and the four others of the registry report data.name = "model" there, where runHeteroTests() reports the formula of the model since 0.16.0 (NEWS.md). Give it the rule of the runner, or say on its help page that it does not. Done in #182: it has the rule, and each of its results is the one runHeteroTests() gives for the test on the same call, extras$streaming = FALSE included, its numbers computed the same way: identical() to it where the BLAS gives the same last bits on every call, as R's reference BLAS does, and equal to the last bits of the BLAS where it does not, as under Intel MKL or the noisy BLAS of cran-blas.yml, which the test allows for (from the review of

    182). On socket workers the model a result carries is the copy that

    travelled to the worker. Four names report "model": "white", "white_bootstrap", "student_bp" and "szroeter". A resampling test still draws from its own stream, and the list is still not a hetero_test_suite. cachedTest(), which returned "model" too, has the rule as well; it gives no extras$streaming, which says how the runner ran a test, and its help page says so. - [x] A grouped run of a multivariate lm. runHeteroTests() refuses lm(cbind(y, x2) ~ x1) with the class of the model context ("needs a model with one response"), but on grouped data it refits the model in each group and records each refusal as a failure of the group: it returns a grouped suite whose every test is NA, with status "error" and issue "model_context", where the refusal of the context should be final (decision 6). A grouped MASS::rlm() fit behaved the same in the first version of step 11 of the model context, and with MASS attached main returned the values of an rlm in each group; step 11 refuses it before any group is refitted, beside a fitWLS() fit. Found by the review of step 11; the same on main at the merge of step 10. Done in step 11 (#171), from its second review, for every refusal of the context: one in a group stops the grouped run with its class and message, a multivariate lm and a penalised gam given a test that needs least squares among them; other failures are recorded in their group, as before. A suite of NA becomes a refusal. - [ ] performInfluenceDiagnostics() and performVIFDiagnostic() on a multivariate lm. Every test, the runners, fitWLS() and bootstrapTestStatistic() refuse lm(cbind(y, x2) ~ x1) with "needs a model with one response"; these two, which are not built on the model context, return a value. On main, with d from contract_data() (tests/testthat/helper-test-recipes.R), performInfluenceDiagnostics(lm(cbind(y, x2) ~ x1, data = d)) returns the Cook's distances as a 120 by 2 matrix, a column for each response; influential as 12 positions among its 240 cells (31, 32, 46, 59, 91, 122, 152, 155, 164, 166, 185 and 201, the seven above 120 rows of the column of x2); and a cutoff 4 / (n - p) that counts the 4 coefficients of both responses, 0.0345, where lm(y ~ x1) gives 4 / 118. performVIFDiagnostic() of the same fit returns the VIF of the design the responses share, 1 for x1, the value of lm(y ~ x1) (and with a second regressor, d$x3 <- rnorm(120) drawn straight after contract_data(), which sets the seed 20261015, 1.005058 for each, as for lm(y ~ x1 + x3)). The decision is whether they refuse it as the tests do. Found in the 0.17.0 release audit; recorded, not fixed. A value becomes a refusal, or none. - [ ] recommendRemediationStrategies() without data. recommendRemediationStrategies(results, model, NULL) stops with "argument is of length zero" for every model, an lm included: identifyVariancePatterns() indexes the NULL it is given. Found by increment 11 of the model context, recorded, not fixed; the same on main at the merge of increment 10. Read the model frame without data, or say that data is needed. A refusal becomes a value, or a clearer refusal. - [x] The deprecation messages of 0.14.0. The names deprecated in 0.14.0 that have a replacement (performBreuschPaganTest(), performNCVTest(), performStudentizedBPTest(), the three *Enhanced functions, rbootstrap_test_statistic(), test(), simulate_type_I_errors(), simulate_power_analysis(), and "student_bp" and "ncv" in runHeteroTests()) warn with the default text of .Deprecated(), which does not say that they go in 1.0.0, as the internal helpers, the 0.15.0 forms and rrunAdvancedDiagnostics() do. Found in the 0.15.0 release audit; no value changes. Done in #182: the ten functions give the message of rrunAdvancedDiagnostics(), now written by one helper for the eleven, as "'performBreuschPaganTest' is deprecated and leaves the exports in heteroTests 1.0.0. Use 'performBPTest' instead.", and the two test names that of the studentized argument of performBPTestRobust(), "Test name "ncv" is deprecated and stops working in heteroTests 1.0.0. Use "cook_weisberg" instead.". The tests of each pin the 1.0.0, and test-public-api.R checks that every deprecated function gives a message of its own. - [x] ht_analyse_failure() labels a failure by its message, ignoring case. It calls a failure a rank deficiency when the message matches "rank|singular" and a data-quality issue when it matches "NA|NaN|missing|Inf", both with ignore.case = TRUE (R/error_handling.R:269-275), and ht_data_cleaning_suggestions() picks its advice by the same patterns. The refusals of a glm that is not a least-squares fit name the test, and two names match: on m <- glm(am ~ wt + hp, data = mtcars, family = binomial), runHeteroTests(m, mtcars, tests = "rank_permutation") stops with "Rank deficiency detected for Rank Permutation: performRankPermutationTest() needs a model fitted by stats::lm(): ..." and the advice to remove collinear predictors, and tests = "high_dimensional" with "Data quality issue detected", from the "na" of performHighDimensionalTest(), and the advice to remove missing values. The other tests refuse it as "Diagnostic execution failed". Match whole words, and NA only in capitals, or give the refusal of such a glm an issue of its own. Recorded, not fixed, in 0.15.0; only the message and the recorded issue change. Done in #182, both ways. The refusal has the class heteroTests_glm_family_error, which the failure analysis reads as the issue "glm_family", final as those of the model context and of the rows of data: on the glm above, runHeteroTests(m, mtcars, tests = "rank_permutation") stops at once with "Model is not a least-squares fit for Rank Permutation: performRankPermutationTest() needs a model fitted by stats::lm(): ...", that class and no advice, and so do the other tests, where White's test tried its four fallbacks first; a grouped run records the issue in each group. The other messages are read word by word, NA, NaN and Inf in capitals only, and the names of the rank tests ("Rank permutation", "rank-based") are not a rank deficiency, so that too few observations for the rank permutation test are no longer one. The notes of summary() give the new issue, and drop the prefix of White's exact fit, "Test cannot be computed", which they kept. From the review of #182: the refusal is no longer a simpleError, as those of the model context are not; catch it by its class or as an error. A diagnostic a user registers under the name of a fallback, as registerDiagnostic("breusch_pagan", f) for White's test, is no longer tried after this refusal, nor after one of data that lack rows of the fit (the item below), as it was not after the other final refusals. The plurals "NAs" and "NaNs" are read as NA and NaN, "singularity" and "singularities" as "singular", and "Spearman rank" is a name too. - [ ] performBoxMTest() with a missing value in group or data returns NA: a row whose group is NA becomes a row of NAs in every group (data[group == gr, ]), and a missing value in data makes its group's covariance matrix NA. Found by the review of #158 (2026-10-26), where the test now refuses the groups on which it is not defined. Either refuse such data with a message that names the rows, or leave the rows out with a warning, as the group tests do ("Removed N observations due to missing values"). Either changes what such calls return. - [ ] performBoxMTest() on a group with a constant variable, on macOS. The check of #158 decides that the covariance matrix of a group is singular by the rank of its data centred at colMeans() (rboxm_refuse_singular(), R/performBoxMTest.R:196). Where colMeans() sums in double precision, as on macOS on arm64, whose long double is a double, the mean of 50 values of 0.2 is 0.19999999999999993, the centred column is a constant of -8.3e-17 rather than zero, and qr() gives the four columns rank 4; the log-determinant is -Inf, and the test stops with the message for a determinant that under- or overflows ("its variables have full rank, and their scale makes the determinant under- or overflow. Rescale the variables.") instead of the one that names the constant variable. test-boxm.R:157 fails so on the macOS (R release) job of every run on main since #158 (runs 40326050314, 40361728314, 40382244911 and 40392607338, the last on 67bf41a), and passes on Linux and Windows. The call stops either way; only the message and its advice differ. Look for a variable that is constant in the group before the rank, or decide the rank on the scale of the uncentred data. Found in the 0.16.0 release audit; recorded, not fixed. - [ ] performFlignerKilleenTest() and ties of decimal arithmetic between different observations. Two different observations can have deviations that are equal in exact decimal arithmetic, as when two pairs of observations of a regressor recorded with few digits differ by the same amounts; the binary values of the data break such a tie by a few units in the last place of the response, which can exceed the tolerance of 8 * eps * s, scaled by the residuals. On iris with every row twice, lm(Sepal.Length ~ Petal.Length) by Species, one of the 106 sets of exact ties stays broken, by 10.4 times eps * s, and the statistic is 8.14863 where the residuals computed exactly, scaled to integers, give 8.14287 (8.13778 before 0.16.0); with + Species, 0.479152 against 0.479156. Two forms of one model can differ by several percent: on 70 observations that repeat 30 values of x recorded with one decimal, y ~ poly(x, 3) + g gives 1.21547, and y ~ x + I(x^2) + I(x^3) + g and y ~ poly(x, 3, raw = TRUE) + g give 1.30280, 7% more: repeated values of x in different groups are different observations, rightly not given one residual, and poly() breaks their decimal ties in its last bits (the second review of #156). On iris itself and on the one-way fits of sleep, PlantGrowth, warpbreaks, InsectSprays and iris[-c(50, 100, 150), ] the value is exact. Found in 0.16.0 with the two items on the test under 0.16.0; recorded, not fixed. A tolerance scaled by the response would cover it, at the cost of merging deviations of continuous data that differ (the review of #156). - [ ] performFlignerKilleenTest() depends on the last bits of the BLAS. On iris with every row twice, lm(Sepal.Length ~ Petal.Length) by Species gives 8.14863 on R's reference BLAS; with its coefficients moved by one unit in the last place, as another BLAS may compute them, it gives 8.13333, 8.14287, 8.14863 or 8.14867, and 8.13333 is what CRAN's check of 0.16.0 with Intel MKL gave for the lm (8.149 for the glm). With + Species the value moves in its sixth digit (0.479151 to 0.479156); the poly() form does not move. So the statistic and the p-value a user sees depend on their BLAS. The likely cause: rfligner_rounding_ties() ties deviations within 8 eps times the largest absolute residual of their group, while the rounding of y - X b scales with |y| + |X b|, the size that rfligner_residuals() already computes; scale the tolerance by it. Found by the review of #172 (whose test of these fits runs off CRAN only); recorded, not fixed. Changes a value on such data. - [x] Two comparisons in the tests failed under the noisy BLAS after #172. The noise a comparison draws depends on every BLAS call made before it in the process, so a test, or a change to code, that runs earlier moves it. On CRAN the F of the variance form test is compared to 1e-8 (blas_tolerance()) on every cell of the model-context matrix, not only on those with missing rows (#184). And the Gaussian glm against lm(..., tol = 1e-11) on a design within 1e-9 of collinear (test-model-eligibility.R) is compared to 1e-4 on CRAN, 1e-6 off it. That design amplifies the last bits: one unit in the last place of the data moves the p-values of the glm and of the lm apart by up to about 5e-7, in the Harrison-McCabe test and in Koenker's. On #185, which changes the streaming code that earlier tests run, the noisy BLAS moved them past 1e-6: Noisy BLAS 2 once (Koenker's), Noisy BLAS 3 twice. The statistics of the default lm, which aliases x3, are at least 4.6e-3 from those of the glm, so the test still tells the two ranks apart. Tests only; no value changes. Done in #186. - [ ] Squared residuals that are equal but for rounding. On lm(y ~ x) with y = 1 + 0.5 x + 0.3 s, where s is 1 or -1, the residuals are 0.3 and -0.3, and their squares differ only in their last bits. With x = rep(1:20, each = 2) and s alternating, Koenker's test gives 8.13 on 1 (p = 0.004); with x = 1:40 and s in the pattern (1, -1, -1, 1), 15.25 on 1 (p = 9e-5). White's test gives 8.2 and 27.6, from the rounding alone. The streaming forms refuse both, saying the test "requires variation in squared residuals", and the studentized statistic stops on non-finite values. The same on 0.17.0 and with #184, whose rule rightly does not fire, since the regressors do not fit the rounding exactly. Decide the variation of the squared residuals in memory as the streaming forms do, on the scale of the residuals. Found by the review of #184; recorded, not fixed. Makes values refusals on such data. - [ ] performVIFDiagnostic() gives a number for an infinite VIF. On a design with exactly collinear columns, such as x3 = x1 * 3 beside x1 (the wf_local_variable workflow of helper-model-context.R), the VIF of both is infinite, and the function returns the diagonal of solve(cor(X)): of the order of 1 / eps where solve() succeeds, its digits set by the last bits of the BLAS, and Inf for every column where it fails, that of x2, 1.0, among them. Under the noisy BLAS of cran-blas.yml the two computations that test compares gave 1.5e15, 1.8e15, 2.25e15 or Inf for x1 and x3, and 1.0 or Inf for x2, as the seed and the call decided. Take the rank of the design first and give Inf for the columns it aliases only. Found by the noisy BLAS in #172, whose test leaves such VIFs out on CRAN; recorded, not fixed. Changes a value on such designs. - [ ] Left from step 1 of the model context (under 0.16.0), on the rows of data matched by number: - Follow-up, from the review of step 1: try the rows in the order of the fit also when the rows picked by number fail the comparison and data has one row per observation. That accepts a fit to d[order(x), ] given the same data numbered again, and character row names that look like numbers. - Known limit, as in 0.15.0: the rule on ties covers data taken in the order of the fit only. If the fit used numbered data with ties, and the same data is given permuted within those ties, it is matched by number and accepted, both before and after step 1. A test that reads a column the model frame does not hold then sees the rows exchanged: the rank permutation test with order_by = "z" gives -0.02142 instead of -0.02619 in the order of the fit. Extend the rule on ties to rows matched by number when the call reads such a column. - [x] Data that lack rows of the fit are refused without the class of the other refusals of rows. rstop_data_rows() (R/interface.R:673, called at R/model_context.R:678) refuses them with a plain stop(). Every other refusal of the rows of data has the class heteroTests_data_rows_error (rstop_data_rows_unmatched()), which the runners treat as final (R/error_handling.R:306). On a fit to the 120 rows of d, runHeteroTests(m, d[1:100, ]) tries the four fallbacks of White's test, each of which refuses the same rows. It then stops with "Data quality issue detected for White" and the advice to "Remove or impute missing values", which the "missing" of "Missing rows" selects (see the item on ht_analyse_failure() above). A tidymodels workflow and a parsnip fit_xy() fit already give this refusal the class (R/ecosystem_integration.R:636-645). Give it the class and keep its message, which the suite pins (tests/testthat/test-model-context.R:287), so that the runners raise it at once. Only the class, and the message of the runners, change. Found by the review of the R Journal article's subsection on the model context. Done in #182: the refusal keeps its message and has the class, so runHeteroTests(m, d[1:100, ]) and runDiagnostics() stop at once with "Data do not match the model for White: White test requires data to contain the rows used to fit the model. Missing rows: 101, 102, 103", that class and no advice. A grouped run, whose groups are refitted on their own rows and so do not meet it, would record it in its group with the issue "data_rows" and no fallback, as it records the other refusals of rows. The workflows and parsnip fits, which told it by its message, now tell it by its class. The refusal is no longer a simpleError, as the other refusals of rows are not; catch it by its class or as an error (from the review of #182). - [x] The streaming tests could overflow on terms that are combinations of others. chunked_regression_r2() (R/performance.R) stacked each chunk under the factor of the chunks before it by qr.R(qr(rbind(factor, rows), tol = 0)), the decomposition of LINPACK (dqrdc2) without pivoting, which makes a Householder reflection from every column, however little is left of it. What is left of a column of the auxiliary design that is a combination of others, as the squares and products of dummies in White's design are, is rounding error; the next such column leaves about the square of that, and so on down. dqrdc2 divides a column by its norm, and when that norm is below the reciprocal of the largest double the result is not finite: the next chunk, or the qr() that reads the factor, stopped on "NA/NaN/Inf in foreign function call (arg 1)". The defect predates #184, which found it: it came with the stacking, in 0.17.0, and is on main at 01164f6. The noisy BLAS jobs of cran-blas.yml on #184 stopped there, on all three seeds, in White's streaming test on a one-way layout of nine groups of ten observations and one of two, a design that pull request tests; R's reference BLAS does not reach it there, but a multiple of a regressor of size 1e-300 does it on any BLAS, in chunks of any size (test-streaming.R). Done in #185: the stack is decomposed by qr() at a tolerance of 1e-13 (rchunk_factor()), which moves to the end a column of which less than 1e-13 of its norm is left after the columns before it, and the factor keeps the rows up to the rank, its columns put back in their order. The rows left out hold only what is left of the columns of the design moved, so the cross-products of the other columns do not change; where no column falls below the tolerance, the factor is that of 0.17.0 to the last bit. What is left of the response is never left out: when dqrdc2 moves it, the factor keeps it as one more row, the norm of its effects past the rank after the reflections of the columns kept (qr.qty(), with the reflections dqrdc2 goes on to make from the columns it moved set to 0, as they can be not finite). The first version of #185 left it out too, and the review of #185 found that this lost the exact-fit decision: dqrdc2 compares the response with its norm about zero over the rows so far, which squared residuals that vary little are almost all of, so what is left below the tolerance can be the whole of the residual sum of squares, and it adds up over the chunks. In 10,000 chunks of 7 of squared residuals 1 + 2.5e-8 x and such a part, whose total sum of squares about their mean is 6e-16 of their sum of squares, the residual sum of squares was 0, an exact fit for White's streaming test, where one chunk and 0.17.0 give 3.16e-18, five times sqrt(.Machine$double.eps) of the total; it is now 3.16e-18 in chunks of either size (test-streaming.R). The factor can now have fewer rows than columns: the rank, the effects, the residual sum of squares and rfactor_regressors_exact() read it through qr(), as before. On 21 fits (continuous regressors, factors, a one-way layout, near-collinear and distant regressors, poly(), weights, a Gaussian glm and three mgcv::gam() fits, the basis of s(x1) among them) in chunks of 7 and 10,000, 106 of the 126 statistics of the three streaming tests are identical() to those of 0.17.0 and the others, each on a stack where a column fell below the tolerance, within 8.4e-16 relative (their p-values within 4.4e-15), with the same degrees of freedom and refusals; the factor is identical() on the 55 stacks where no column fell below it. On the 17 fits of the review (100 streaming results), one of 100,000 observations among them, the largest change is 2.0e-14 relative, less than the 2.2e-14 to 4.5e-14 by which White's streaming test differs from performWhiteTest() there in 0.17.0 as since. A decomposition of LAPACK with column pivoting, tried first on the branch of #184, moved White's streaming statistic on the basis of an mgcv::gam() fit by 1.5e-8, and was not used. - [ ] A column of very small norm can still overflow the stacking of the streaming tests. rchunk_factor() (R/performance.R) keeps a column of which at least 1e-13 of its norm is left after the columns before it, so its guarantee is relative: a column whose norm is below about 5.6e-296, 1e13 times the reciprocal of the largest double, can leave less than that reciprocal, and dqrdc2 divides by it. On lm(y ~ x1 + x2) with x1 = 1e-150 * u and x2 = 1e-150 * (u + 1e-6 * w), 60 observations, whose squares and product in White's design are of size 1e-300 and nearly collinear, performWhiteTestStreaming() stops on "NA/NaN/Inf in foreign function call (arg 1)" in chunks of 7 and of 10,000, in 0.17.0 and since

    185 alike, where performWhiteTest() gives 25.476; at 1e-140 it gives

    25.476 too. Found by the review of #185. Scale the columns of the stack before decomposing it, or refuse such a design with a message that says why; changes a value only where the test stops today. - [ ] Left from steps 8 to 10 of the model context, recorded, not fixed. Moved from 0.17.0 at its release (2026-10-30), where the steps that found them are done; each changes what a call returns: - From step 8: bootstrapTestStatistic() refits each replicate at the tolerance of lm(), 1e-7, as its refits of the formula did, so a fit whose rank was decided at a smaller tolerance and differs, an lm fitted with a smaller tol or a Gaussian glm (1e-11) with a nearly collinear column, is refused ("data does not reproduce the fit"), as such an lm was before. Refitting at the tolerance of the fit would give them values. A refusal becomes a value. - From step 8, left by step 11: fitWLS() of an mgcv::gam() fit whose smooths are all fixed (s(x, fx = TRUE)), the least-squares fit of its design, refits that design but returns an lm whose terms rebuild the variables of the formula, not the basis of the smooths, so the tests refuse it as a fit whose design does not reproduce its residuals (M8), as since step 8. Refuse such a fit in fitWLS() itself, as a penalised gam is since step 11, or keep its basis there, so that it can be tested. A refusal moves, or becomes a value. - From step 9: performInfluenceDiagnostics(), and so runDiagnostics()$influence, returns the Cook's distances padded with NA, one per row, as step 9 pads an output; but influential holds their positions among the rows, where the same fit with na.omit gives their positions among the observations (the names are the same). Decide which the positions count. A value changes, or none. - From the review of step 10: augment() of the results of a workflow whose preprocessing drops rows of new data, a recipe step applied to them (step_filter() or step_naomit() with skip = FALSE), refuses its data, since the preprocessing does not say which rows of data it kept; the runners take those data, whose rows the alignment confirms. Find the rows the preprocessing kept, or keep the refusal. A refusal becomes a value, or none. Measured on the second review of step 10: forging each row on its own, which says which rows a step that maps each row on its own kept, costs 2.75 ms a row (0.33 s for 120 rows, about 14 s for 5,000, where forging the whole data takes 3 ms), and a step that makes a row from other rows gives other values row by row; not adopted, the refusal kept. - From the third review of step 10: augment() refuses the data of a workflow whose recipe makes a row from other rows (step_mutate(cs = cumsum(x)), dplyr::row_number(), a mean over dplyr::lag(), step_lag()) or sorts data already in its order (step_arrange()), where 0.16.0 bound each row of data in the order of the fit its own values; the runners take them. A way to accept them: data in the order of the fit whose columns the preprocessing leaves unchanged identify each row (no two rows alike in them) and match the mapped rows in that order, so that each mapped row is the row of data at its position. A refusal becomes a value, or none. - [ ] Found by the model-eligibility work (the Supported models page), recorded, not fixed, as they change what a test computes rather than which models it accepts. Those below are not settled by the model context; the others are under 0.16.0 and 0.17.0: - rvalidate_against_reference("white", ...) compares White's statistic with car::ncvTest(), the Cook-Weisberg score test, so it reports a mismatch on every model. Compare with lmtest::bptest() on the squares and cross products, as test-model-eligibility.R does. Deprecated; changes what it returns. - performInfluenceDiagnostics() counts the aliased coefficients of a rank-deficient fit in p for its default cutoff, 4 / (n - p), and so runDiagnostics() does; the rank is the number of coefficients. Changes a value. - generateHeteroRecommendations() refuses a rank-deficient fit (it checks the model with the checks of validateTestInputs()), which the tests and runHeteroTests() accept since 0.16.0. Accepting it makes a refusal a value. - [x] Koenker's and the Breusch-Pagan tests fit the squared residuals exactly on the same designs, since their auxiliary regression is on the regressors: on the paired design of White's exact fit (under 0.16.0) performKoenkerTest() gives 60 on 30 degrees of freedom (p = 0.0009), the number of observations, as lmtest::bptest() does, and performBPTest() 63.25 on 30 (p = 0.0004), a function of the spread of the squared residuals alone, whatever their pattern. Found by the review of the follow-up of N2. Refuse such a fit in both, at the threshold of White's test, in a pull request of their own. Refuses calls that return a value. Since 0.16.0 both warn on that design, whose 30 levels of two observations are fixed effects for small groups (the item on them, under 0.16.0); the refusal is still to do. Since #183 performKoenkerTest() no longer calls summary.lm(), so on such a fit it no longer gives that function's warning "essentially perfect fit: summary may be unreliable": on lm(y ~ x1 + f) with 10 levels of 2 observations 0.17.0 warned and #183 gives the same 20 on 10 without it. Done in #184: both tests, and their streaming forms with the same message, refuse an auxiliary regression whose residual sum of squares is at most sqrt(.Machine$double.eps) of the total, the rule of White's test, now one function that White's test calls too (rauxiliary_exact(), R/performWhiteTest.R). On the paired design they refuse where they gave 60 and 63.25 on 30, and on lm(y ~ x1 + f) where they gave 20 and 28.03 on 10, before the warning on fixed effects for small groups, as White's test does. The message is White's, built by the same code (rrefuse_exact_fit()): "The auxiliary regression of Koenker's test fits the squared residuals exactly: the squared residuals are a combination of its 31 independent terms with its intercept, for 60 observations. Its R-squared is 1 and its statistic the number of observations, whatever the data. The design forces the exact fit, as a factor with two observations per level does, however many levels it has; use more observations per level."; the Breusch-Pagan test's says its statistic is "a function of the spread of the squared residuals alone, whatever their pattern". Its class is a general one, heteroTests_exact_fit_error, not White's, whose name and field regressors_exact are about White's auxiliary design; White's refusal carries it too, after its own heteroTests_white_exact_fit, with its message unchanged, so that one class catches every exact fit. ht_final_exact_fit() reads the general class, and the new refusals say that the regressors fit the squared residuals exactly, so in runHeteroTests() they are final: "breusch_pagan" and "koenker", streamed or not, stop with the message where they returned the statistic, without the Cook-Weisberg test, their last fallback, under their name; the runner drops the class, as it does on White's refusal (keeping it, as #182 does for the refusal of rows, is left to decide). A fallback that refuses an exact fit ends the search (a refusal of class heteroTests_exact_fit_error whose regressors_exact is not FALSE; other refusals still go on to the next fallback, as White's refusal of a saturated design, with regressors_exact = FALSE, goes on to the Breusch-Pagan test, 3.63 on 6 on 22 observations). So "high_dimensional", which refuses 20 observations, no longer returns the Breusch-Pagan test's 28.03 on 10 under its name on lm(y ~ x1 + f), and "spatial_hetero" without listw stops with its own refusal on 12 subjects of two, where it returned the Breusch-Pagan test's 12.37 on 12. The audit of the other tests of an auxiliary regression of the squared residuals, on the paired design: the studentized statistic (performStudentizedBPTest(), performBPTestRobust(studentize = TRUE) with or without the bootstrap, "student_bp"), 60 on 30, performBPTestRobust(studentize = FALSE), 63.25, and bootstrapTestStatistic() of either test (p = 1 for Koenker's) refuse it with the refusal of the test they compute; the wild bootstrap, which gave 60 with p = 1, every replicate an exact fit too, refuses it before the replicates; the Cook-Weisberg test with var_formula = ~ treat + subject (63.25 on 30), Harvey's test of the regressors (63.07 on 30, and an infinite F studentized), the high-dimensional test with variance_threshold = 1, which keeps every component (60 on 30), and the variance form test with a variance regressor constant in each pair and against = ~ subject (F = 4.06e29 on 28 and 30) refuse it by the same rule, with regressors_exact unknown (NA) for the Cook-Weisberg, Harvey and variance form tests, whose regression need not be on the regressors. Not exact fits there, and unchanged: the Cook-Weisberg test of the fitted values (0.608 on 1), Harvey's of the fitted values and their square, the high-dimensional test with its default 26 components (56.84 on 26), Glejser's and Park's tests of one variable, and Bartlett's test of groups of two; Levene's, Brown-Forsythe's and Fligner-Killeen's refuse groups of two already. autoTransform(), which chooses by the p-value of the Breusch-Pagan test, stops with its refusal on such a design. One step away, with a subject observed a third time or 20 subjects of three, every test gives the value it gave. In inst/validation/fixed-effects-size.csv the T = 2 cells of the two tests, 10 to 200 subjects, are such fits (Koenker's mean statistic is n, and the Breusch-Pagan test rejects in 25.8% to 100%): the README says so, and the rows are kept as measured; no T = 3 or other cell is one. One test of the warning moved from two observations a cell to three (test-fixed-effects-warning.R, the crossed a * b). Refuses calls that returned a value. - Found by the audit of #184: on the paired design Glejser's test of treat, which takes the values 0 and 1 in each pair, gives 1.1e-15 on 58 degrees of freedom (p = 1), since the absolute residuals of a pair are equal and their regression on treat has a slope of 0 whatever the data: a test that cannot reject. Any test of a variable that varies only within pairs, balanced as treat is, gives 0 there. Not an exact fit, and not changed. Refuse such a variable, or say so on the help pages; changes a value, or none. - [x] To fix before 0.18.0 is released: the rule refuses a fit that the data make exact, not the design, and its message blames the design. It compares the residual sum of squares of the auxiliary regression with the total sum of squares of the squared residuals, and one gross error in a level of two observations can dominate that total. On lm(y ~ g) with 9 groups of 10 and one of 2 (y normal with mean 50 and sd 5, set.seed(1)), one value of the pair multiplied by 100 leaves a residual of 2336 against an sd of 4.33; the dummy of the pair fits its two equal squared residuals, and the ratio falls below sqrt(.Machine$double.eps). 0.17.0 gives Koenker's test 92 on 9 and the Breusch-Pagan test 2069.35 on 9, and autoTransform() chooses boxcox. #184 refuses both tests, their streaming forms and the runner, and autoTransform() stops, saying "The design forces the exact fit … use more observations per level", which is false here. White's test has refused this case since 0.16.0. With y ~ x + f a shift of 200 sd is still computed and one of 500 is refused; if x varies within the small level, nothing is refused. Refuse only a fit the design forces: push a fixed pseudo-response through the fit, and refuse only if the same regressors also fit its squared residuals exactly, so that White's test gives its value there too. Found by the review of #184; to be fixed in its own pull request. Makes refusals values. Done in #187: the tests refuse an exact fit only when the design forces it. Each test pushes a fixed pseudo-response, standard normal draws from a fixed seed by R's default generators, after which the state and the kinds of the session's generator are put back, through the QR decomposition of the fit, with its weights, and refuses only when the same auxiliary regressors fit the same function of its residuals exactly too (rforced_exact_fit()): their squares, their logs for Harvey's test, their centred squares for the studentized statistic. When the squares of those residuals do not vary, whether the design forces the fit is not known, and the test refuses it without saying so. The first version of #187 used the fractional parts of i times the golden ratio, whose pattern its review found the designs could follow, by the order of their rows: on 72 subjects of two visits sorted by visit a fit the design forces gave Koenker's test 144 on 72, and on 4 units of 13 periods sorted by unit a fit the data make exact was refused as forced, where with the rows shuffled it gave 52 on 15. The streaming forms carry those squares as one more response in the pass they make, and decide as the exact forms do; the stacking of #185 keeps what is left of both responses (rchunk_factor()). On the layout above the dummies fit the squared residuals to within 1.1e-9 of their total, and Koenker's test gives 92 on 9 and the Breusch-Pagan test 2069.35 on 9, streamed or not, through runHeteroTests() too, and autoTransform() chooses boxcox, the values of 0.17.0. White's test gives its value there too, 92 on 9, and n R^2 = 80 on the pairs of test-performWhiteTest.R whose residuals are +(1 + x) and -(1 + x): a refusal becomes a value. Whether its regressors alone fit the squared residuals, which decides the runner's fallback, is asked of the squared pseudo-residuals too. A fit without a QR decomposition, an mgcv::gam() tested on its design or an lm fitted with qr = FALSE, is refused when the regression fits exactly, with a message that says only that it does, not that the design forces it. autoTransform() leaves out a candidate the Breusch-Pagan test refuses, since a candidate drops the rows where its response is not finite and so can fit another design, and stops with the first refusal only when every candidate is refused. A design that forces the fit, the paired design or a factor of two observations a level, is refused as in #184. - [ ] Left from the review of #187, recorded, not fixed. An mgcv::gam() fit keeps no QR decomposition, so on an exact fit it is refused without the reason and the advice, on a paired design too, which forces it; for a gam whose smooths are fixed the QR decomposition of its weighted design would tell. And on a fit the data make exact the F statistics come back as values, as in 0.17.0: Harvey's studentized test gives Inf on 1 and 78 on pairs whose log squared residuals are x exactly, and the F of the variance form test can be a ratio to rounding error. Say so on their help pages, or refuse them. - [x] The degrees of freedom of performBPTest() and performKoenkerTest() are the number of columns of the design but its intercept, not the rank of their auxiliary regression less its intercept, as White's are since N2: on a model without an intercept whose columns span the constant, such as lm(y ~ 0 + group + x1) with a factor of four levels, the auxiliary regression adds an intercept that the dummies already span, and the tests count 5 where the rank says 4. In the design of contract_data() the Breusch-Pagan test gives 8.653 on 5 and Koenker's 10.52 on 5, where lm(y ~ x1 + group), the same column space, gives the same statistics on 4; the streaming forms refuse that model ("auxiliary regression became rank deficient"), as before the pull request of N3. Found by that pull request. Take the rank, as White's test does; changes a p-value, and makes the streaming refusals values. Done in #183: both tests take the rank of the auxiliary regression less its intercept, decided by the code of White's test on the regressors centred one by one (rauxiliary_columns(), R/performWhiteTest.R), and their streaming forms the rank they decide in chunked_regression_r2() (R/performance.R) by the same rule, where they refused a rank deficiency. On lm(y ~ 0 + group + x1) the Breusch-Pagan test gives 8.653 on 4 (p = 0.0704, was 0.1237 on 5) and Koenker's 10.52 on 4 (p = 0.0325, was 0.0617 on 5), the values of lm(y ~ x1 + group) and of lmtest::bptest(), which takes the rank of its auxiliary fit too, so the package now agrees with it there; the streaming forms give them where they refused, and performBPTestRobust(studentize = FALSE), whose interval follows the degrees of freedom, and the registered "breusch_pagan" and "koenker" follow: runHeteroTests() asked to stream them (chunk_threshold_mb) gives the streamed values, where it fell back on the Cook-Weisberg test, 3.79 on 1, under their names. Changes a p-value. The auxiliary regression is fitted from the matrix of the columns kept (rauxiliary_fit()), on the centred columns where those as they are lose one, not by lm() from a formula on the columns as they are, and that changes the statistic on three kinds of design of full rank, from a wrong value to that of the streaming forms, which had it already, and of lmtest::bptest() but on the glm below, whose design it refits by lm.fit(), dropping the regressor too (from the review of #183): a model without an intercept with a regressor far from zero, which that fit dropped as a combination of its intercept while the degrees of freedom counted it, lm(y ~ 0 + I(x1 + 1e8) + x2) in the design of contract_data() giving 2.850 on 2 (p = 0.241) for 16.72 (p = 0.0002) and Koenker's 2.372 (p = 0.305) for 13.92 (p = 0.001), the values of lmtest::bptest() with varformula = ~ x1 + x2; a Gaussian glm, which keeps at a tolerance of 1e-11 a regressor that lm() would alias, glm(y ~ I(1e6 + x1 / 10) + x2) giving 2.005 for 12.45 and 1.457 for 9.044, the values of lm(y ~ x1 + x2); and a regressor named scaled_sq or e_squared, which the formula of the Breusch-Pagan or the Koenker test took for its response, lm(y ~ scaled_sq + x2) giving 102.0 for 12.45 and lm(y ~ e_squared + x2) 0.014 for 9.044. Changes a value there; on the 16 other fits the review compared, one of them a rank-deficient lm tested on the estimable part of its design, every result was identical() to 0.17.0's. A model whose only regressor is constant, lm(y ~ 0 + one), has no regressor beyond the intercept and is refused by all four ("requires at least one predictor beyond the intercept"), where the exact tests gave 0 on 1 and the streaming ones refused it as rank deficient; the exact and streaming forms refuse it with the error of a model with an intercept alone, a simpleError with the same message, as the "rank deficient" refusal was a simpleError too, of the rassumption_violation template of .std_error(), whose "Results may be unreliable" does not fit a refusal. Koenker's streaming test named the Breusch-Pagan test in that refusal, and names its own. runHeteroTests() on that model, which returned those values, stops with the refusal, as on lm(y ~ 1), and advises "Fit a model with a regressor that varies", where it advised removing one, its only regressor, as it did on lm(y ~ 1) when data held a constant column: on a model whose design is constant the constant columns of data are no longer advised away (ht_constant_design(), R/error_handling.R). The audit of the other tests of an auxiliary regression of the residuals: performWildBootstrapTest() reported the number of regressors as its degrees of freedom and reports the rank, and computed its statistic, for the data and each replicate, by the formula of lm() too, and computes it on the auxiliary regression of Koenker's test, so that on the far-from-zero model it gives 13.92 where it gave 2.372, the statistic of x2 alone, and a p-value of that statistic; its values on other fits are those of 0.17.0 (four fits at a fixed seed, to the last bit), and it refuses the model whose regressors are all constant, and a model with an intercept alone, lm(y ~ 1), on which it gave 0 on 1, p = 1, a test that cannot reject. The studentized statistic (performStudentizedBPTest(), performBPTestRobust(studentize = TRUE), "student_bp"), the Cook-Weisberg test (var_formula too), Harvey's test and the variance form test took the rank already; Glejser's, Park's, the spread-level and the Davidian-Carroll tests regress on one variable; and the high-dimensional test counts the principal components it keeps. No study of inst/validation/ fits such a model. - The studentized statistic (performStudentizedBPTest(), performBPTestRobust(studentize = TRUE), "student_bp") regresses the centred squared residuals on the design of the fit without adding an intercept, as lmtest::bptest() does, where performKoenkerTest(), whose statistic its help page says it computes, adds one. On a model without an intercept whose columns do not span the constant, lm(y ~ 0 + x1 + x2) in the design of contract_data(), it gives 1.507 on 1 degree of freedom (p = 0.220), the value of lmtest::bptest(), and Koenker's test 5.636 on 2 (p = 0.060). With an intercept, or columns that span the constant, the two agree. Found by the audit of #183. Add the intercept, or say on its help page that it differs there; changes a value, or none. - White's auxiliary design is built from the regressors centred one by one, so its rank does not depend on where a regressor that enters the model linearly is located, but a product or a power of a variable located far from zero inside the model stays ill-conditioned, and can lose a column of the auxiliary design while lm() keeps every coefficient. On 200 observations of two standard normal regressors (set.seed(1); x1 <- rnorm(200); x2 <- rnorm(200), y <- 1 + x1 + x2 + rnorm(200, sd = exp((x1 + x2) / 4))), lm(y ~ I(x1 + 2e3) * I(x2 + 2e2)) gives 46.83 on 7 degrees of freedom where lm(y ~ x1 * x2), the same model, gives 51.90 on 8, and lm(y ~ I(x1 + 2e3) + I((x1 + 2e3)^2)) 8.05 on 3 for 11.29 on 4. What counts is the shift relative to the spread of the regressor. On ten data sets of 200 observations (five seeds of standard normal and of U(0, 1) regressors, the response as above), with x1 shifted by k standard deviations and x2 by k or k / 10 of its own, and k from 100 to 40,000 in steps of a factor of 1.12: up to k = 700 the degrees of freedom held in all three designs and the statistic moved by at most 3e-5 relative (1.1e-7 up to k = 200); White's test lost one column from k = 790 to 2,000, the square first and the uniform regressors before the normal ones; and lm() aliased the term itself from k = 3,200 (the square, and the interaction with both shifted by k) to 11,000 (x2 shifted by k / 10), before White's test sees it. With I(x1 + 1e4) * I(x2 + 1e3) on the data above the fit is additive, and the test gives 35.02 on 5, the value of lm(y ~ x1 + x2). So shifts of 1e3 and 1e2 keep the degrees of freedom on standard normal regressors, 1,000 standard deviations, and lose a column on U(0, 1) ones, 3,300 to 3,700: 7 for 8 in three seeds, the statistic 0.5% to 6.8% lower. A year from 1990 to 2020, about 220 standard deviations from zero, and its square, year * z and income * age give the value of the centred model to seven digits or more. Found by the review of the follow-up of N2; the examples and thresholds corrected by the review of #149. An optional improvement: build Z from the orthonormal basis Q of qr(X_c), whose squares and products span the same columns as those of the centred regressors when the cross products are in, and only then. Changes a value on such designs. - On lm(y ~ poly(x1 + 1e6, 2) + x2, data = d) the model context refuses d, the data of the fit: the basis of the polynomial computed again from d through the predvars of the terms differs from that of the model frame by more than the tolerance of the comparison, and the tests stop with "cannot match the rows of data to the observations of the model". Without data, or with poly(x1 + 1e3, 2), White's test gives the value of poly(x1, 2). Found by the review of the follow-up of N2, as on main before it. A fix makes such calls return a value. - [ ] Defects found during the 0.12.0 and 0.13.0 work, left for 0.16.0 so that 0.14.0 changes no value. Each fix that changes a value is announced in bold in NEWS.md. The one below is not settled by the model context; the others are under 0.16.0 and 0.17.0. - The unexported performWhiteTestEnhanced(bootstrap = TRUE), through bootstrap_white_test(), takes the log of a log response twice, drops an offset given as an argument and takes rows by position. Remove it or fix it. No exported value changes.

0.19.0: release-grade evidence and the open decisions

Moved from 0.16.0 on 2026-10-26: the evidence for 1.0.0 at the grade of the release gate in inst/validation/README.md, measured on the code that will be frozen, and the decisions still open.

  • Re-run the full sweep at N_MC = 5000, so that the release gate in inst/validation/README.md ([0.042, 0.058]) applies to every exported test, not only Passes A and B. At 400 replications the Monte Carlo standard error is 1.1%, so the Gaussian sizes between 0.068 and 0.072 (Cook–Weisberg and NCV, Spearman, Levene) cannot be told apart from noise. Pass A, at 5000 replications, put Cook–Weisberg at 0.044. Moved from 0.16.0 (decided by the author on 2026-10-26): 0.16.0 does not need it, and run on the code before the freeze it certifies that code.
  • The bootstraps that estimate the weights again, at the release gate. The 0.15.0 default of reestimate_weights rests on rates measured at 2000 replications with B = 499 (inst/validation/weighted-fits-size.csv), where the 99% interval around 0.05 is [0.037, 0.063] and all four cells are inside it. Against the release gate's interval at 5000 replications, [0.042, 0.058], the wild bootstrap with 150 observations, 0.0605, is outside and the White bootstrap, 0.0415, just below it; against [0.041, 0.057], around 0.049, the rate of an exact test with B = 999, the wild bootstrap is outside. Measure the four cells of block 4 at 5000 replications with B = 999, the default, beside the true weights in the same design. From the time per sample measured in 0.15.0 that costs about 36 to 50 CPU-hours for the four cells, and about 7.6 for the two cells of the wild bootstrap alone. If the wild bootstrap stays outside the gate at 150 observations, say so on its help page.
  • The validation rows the R Journal article reads, on the code to be frozen. The article and its supplement read rates at render time from ten studies in inst/validation/:

    • seven since the article's update to 0.17.0: weighted-fits-size.csv, glejser-skewed-errors.csv, harrison-mccabe-size-power.csv, modified-wald-size-power.csv, offset-refits-size.csv, variance-form-dnvk-size-power.csv and szroeter-design-null-size.csv;
    • three since its additions of 2026-11-02 (heteroTests-paper #6): pass-a-size-power.csv, wild-multiplier-size.csv and fixed-effects-size.csv.

    pass-a-size-power.csv was last written in 0.7.1 (2026-08-31). Five of the first seven were last written in 0.13.0 or 0.15.0, and so was wild-multiplier-size.csv (2026-10-15). offset-refits-size.csv and fixed-effects-size.csv were written in the work on 0.16.0 (2026-10-18 and 2026-10-23), and szroeter-design-null-size.csv the day before 0.16.0 was released (2026-10-26). None was measured on 0.17.0. The full sweep at N_MC = 5000 (above) may replace the Pass A rates the article reads.

    The README says which rows predate a later change to their procedure, not the version each block was measured on. One such row is the Szroeter row of the known-weights block of weighted-fits-size.csv, measured before 0.16.0 took the test's null moments from the design, and not run again.

    Re-measure every row that predates a change to its procedure on the code to be frozen, and record in the README the version on which each block was measured, so that the article can say it. Found by the review of the article's update to 0.17.0; the last three studies were added after the article's additions.

    The article's range for fixed effects for small groups, 22% to 98%, comes from fixed-effects-size.csv, whose T = 2 cells of the Breusch-Pagan and Koenker tests are refusals since 0.18.0 (#184; the README says so, and the rows are kept as measured). The article reads only the cells of 30 subjects of 3 and of 4, 100 of 8 and 200 of 4, none of them an exact fit. - [ ] The performance study the R Journal article reads, on the code to be frozen. The article and its supplement read their timings and memory allocations from three extracts in the article's repository, paper/generated/performance_*.csv. paper/scripts/performance_study.R wrote them under heteroTests 0.11.2, and they were not written again, so the article says they do not measure the current code. Since then the exact Breusch-Pagan and Koenker tests have come to read the fit through the model context, the cache key has changed, and the streaming code has been rebuilt twice: on the stored design in 0.16.0 and on a stacked QR in 0.17.0. No milestone ran the study again. The script calls run_benchmark_suite() and generate_benchmark_report(), which move to the companion package (see the row of Decisions needed on its name, due in 0.19.0). The suite's ncv entry also times the internal .ncv_test(model) (R/benchmarking_system.R:363-368), where in 0.11.2 it timed the exported performNCVTest(model). Rewrite the script so that it times the exported tests and calls no function that leaves the package. Run it on the code to be frozen, and record the run in the article's PROVENANCE.md. Found by the review of the article's restructure to the journal's 20 pages. - [ ] Provenance of a study run in chunks. Commit the script before a chunked run is launched, and record its md5 in the settings of each part, so that MERGE refuses parts written by another version of the script. wild-multiplier-size.R predates the convention: its parts carry no md5, and adding one now would make MERGE refuse them. - [ ] The open decisions. This release settles the rows of Decisions needed (below) that are still open and due before 1.0.0, so that none is open at the freeze.

1.0.0: freeze

  • Remove everything deprecated in 0.14.0 and 0.15.0, or turn it into .Defunct() stubs that name the replacement.
  • test-public-api.R asserts the frozen export list exactly.
  • A failed test keeps its name in runHeteroTests() (decided by the author on 2026-10-23). From 1.0.0, when a test that was asked for fails, the runner returns a failed result under the name asked for, with an NA p-value and the reason it failed. The substitute that runs in its place is returned under its own name (for example white_simplified), and print() and summary() of the suite show both. Code that reads $white$p.value can then never get the value of another test. It changes what the runner returns, an API change, so it waits for 1.0.0 and is not part of 0.16.0. It settles the item on the fallback of runHeteroTests() under 0.16.0. Not implemented.
  • distribution = "none" in performWildBootstrapTest() (decided by the author on 2026-10-25). A third value of distribution, the plain residual bootstrap of the leverage-standardised, centred residuals, without a multiplier (v = 1), with "rademacher" kept as the default. In inst/validation/wild-no-multiplier-size.R it held the level with skewed errors from 50 observations, where both multipliers need about 200, at a cost of 1.4 points of power at 30 observations. It adds a value to an argument and changes no existing call; its method string should say that no multiplier is used. Not implemented.
  • Reference index of the documentation site (tools/reference-index.R) grouped by the frozen API: tests by family (auxiliary regression, group-wise, rank-based, time series, panel and spatial, resampling), remediation, simulation, plotting and reporting.
  • Vignettes. There are eight, and tutorial, using_heteroTests and comprehensive_guide overlap. Consolidate them into a short set that does not:
    • getting started;
    • choosing a test, turning the t5 and power tables into advice;
    • remediation;
    • theory and references.
  • Publish the stability policy from What 1.0.0 means in the README and on the documentation site.
  • NEWS.md for 1.0.0 with the complete old → new migration table.
  • Full --as-cran on every CI flavour, plus win-builder, then submit.
  • Update the R Journal manuscript, kept in its own repository, to the 1.0 API and submit it. Submitting on the frozen API avoids publishing function names that are about to be deprecated.

After 1.0.0

  • First-class panel and spatial heteroscedasticity workflows.
  • Helpers that bridge detection to conditional-variance modelling (ARCH/GARCH) for the time-series path.
  • An experiment/report artefact (model-card style) summarising a diagnostic run.
  • Streaming for more tests where it is meaningful, under a single chunk_threshold_mb policy.
  • Stronger feasible-WLS and auto-transform helpers: iterated feasible GLS, an estimator of the additive variance function that keeps its fitted variances positive, and a Box–Cox search.
  • A form of performVarianceFormTest() that does not need the standardized errors to have a constant fourth moment, following the robust statistic of Wooldridge (1991).
  • A variance-regressor argument for performBPTest() and performKoenkerTest(), the varformula of lmtest::bptest(), so that a fit with fixed effects for small groups, on which the tests warn since 0.16.0, can be tested against its regressors without the dummies. On the balanced designs of inst/validation/fixed-effects-dummies.R, where the tests with the dummies reject up to 100% of the time at the 5% level, the squared residuals regressed on x alone kept the level: 4.6% to 5.5% for Koenker's statistic and 4.3% to 5.6% for the Breusch-Pagan statistic. Unbalanced designs are untested. A new argument.
  • The test of Juhl and Sosa-Escudero (2014, Journal of Econometrics 178(3), 484-494) of heteroskedasticity after fixed effects estimation, for panels of many units observed a fixed number of times, the setting where White's, Koenker's and the Breusch-Pagan tests warn. It needs the user to name the factor that is the unit, which no test of the package takes today. A new export; its reference implementation, and whether it requires a balanced panel, are to be checked first (only the abstract could be read in 0.16.0).
  • A single parametrised accuracy-validation test, with reference coverage broadened to skedastic and sandwich.
  • The companion package for the dashboard and the benchmark suite (decided on 2026-10-14; not yet named).

Candidate tests

None of these is scheduled. Each would be a new export, so each has to clear the release gate in inst/validation/README.md before it ships: agreement to 1e-8 with an established implementation or with a reconstruction of the primary reference, simulated size inside the gate, and power against the alternative it is built for.

The list is arranged by where the test can be found today, because that decides both what there is to validate against and what the package would add. Availability was checked on 2026-10-09 by searching CRAN, the R documentation sites and the web for each test by name. "No R implementation found" reports that search. It is not proof that none exists.

No R implementation found

A first implementation in R. Where another system has one, it is the reference; where none does, the guard is a reconstruction of the primary reference and simulated size.

  • Modified Wald test for groupwise heteroscedasticity in a fixed-effects panel (Greene 2000; Baum 2001). The panel tests cover an individual effect and cross-sectional dependence, not unequal variances across units. Reference implementation: Stata's xttest3. The Python package panelbox (1.0.2) has a function of that name that computes a different statistic, a likelihood-ratio form. A second search on 2026-10-10 found no R implementation either. Measured for 0.13.0 and not adopted: it does not hold its level. In the designs of inst/validation/modified-wald-size-power.R, at 5000 replications, the test as xttest3 computes it rejects a true null hypothesis at the 5% level this often:

    Errors Units T = 5 T = 10 T = 30 T = 100
    Gaussian 10 0.927 0.681 0.282 0.100
    Gaussian 30 1.000 0.966 0.584 0.183
    Gaussian 100 1.000 1.000 0.928 0.361
    t5 10 0.966 0.876 0.621 0.367
    t5 30 1.000 0.999 0.944 0.717
    t5 100 1.000 1.000 1.000 0.982

    Two unbalanced panels are at 0.809 and 1.000 under Gaussian errors. Each unit's term is divided by a variance estimated from the same few residuals, and the excess grows with the number of units. In long panels the error reverses, because the statistic tends to a chi-squared with one degree of freedom fewer than its reference: with 2 units and 1000 periods it rejects 0.014 of the time. Three of the study's 40 null cells fall inside the release gate, all at 300 or 1000 periods, where the two errors happen to cancel, and under t5 errors it still rejects 0.129 of the time with 10 units and 0.235 with 30 at 1000 periods. Before a test of this hypothesis can ship it needs a different variance estimate or a small-sample correction, which makes it a different test. Two leads, neither part of the study: Bartlett's test on the within residuals held its level under Gaussian errors in an exploratory run but, being a normal-theory test, not under t5 errors; and Juhl and Sosa-Escudero (2014, Journal of Econometrics 178, 484-494) derive studentized tests for heteroscedasticity after fixed-effects estimation with the number of periods fixed, whose fit to this hypothesis has not been checked. Greene's page 598, which Baum follows, could not be obtained, so whether Greene's own form differs from Baum's was not checked. - A joint LM test of homoscedasticity in a one-way error-component model (Baltagi, Bresson and Pirotte 2006), against heteroscedasticity in the individual effect and in the remainder error together. - Pagan–Hall (1983), the test of constant variance after instrumental-variables estimation, where Breusch–Pagan and White are valid only under further conditions. Reference implementation: Stata's ivhettest. The package accepts no instrumental-variables fit today, so this one starts with the input layer. - Kelejian–Robinson (1998), a joint test for spatial autocorrelation and heteroscedasticity. sphet::kpjtest() is a different test by Kelejian and Piras. - Newey–Powell (1987), a test built on asymmetric least squares: under constant variance the expectile regressions have equal slopes. expectreg fits expectiles and has no such test. - Tests in nonparametric regression, which have power against any smooth departure from constant variance and need no variance regressors to be named: Eubank and Thomas (1993), Dette and Munk (1998), Zheng (2009), and Chown and Müller (2018). The cost is a bandwidth or a difference sequence to choose. - A consistent test of a variance function: Dette, Neumeyer and Van Keilegom (2007), Wang and Zhou (2007), and Samarakoon and Song (2011). performVarianceFormTest() has power against the terms it is given; these have it against any departure, at the cost of smoothing. Stage 1 of the first measured for 0.13.0, and not exported: as defined, it does not hold its level. The 2007 article could not be obtained; a prototype in plain R follows the authors' submitted manuscript of 2005, whose Tables 4.1 and 4.5 it reproduces in level and to within a few points in power. In the designs of inst/validation/variance-form-size-power.R, with the variance function in one variable and the mean a linear model, the Cramér-von Mises statistic is inside the release gate in 4 of its 12 Gaussian null cells at 5000 replications, all four with constant variance at 150 or 400 observations; it rejects a true exponential or power variance function 0.019 to 0.036 of the time at 50 to 400 observations. The bootstrap draws its data from the least-squares fit of the variance function, on which the statistic's null distribution depends: drawn from the true shape, on the same samples, three of the four such cells come to 0.047 to 0.051. Against a departure the added terms of performVarianceFormTest() do not span it has the power that test lacks, 0.750 against 0.089 at 400 observations; against the other alternatives it has less. Design note, prototype and evidence: inst/validation/variance-form-dnvk-design.md and the variance-form-dnvk-* files beside it. What to do next is under Decisions needed. Wang and Zhou (2007) assume a parametric mean, which is this package's setting; Samarakoon and Song (2011) could not be obtained. Stage 1 of Wang and Zhou (2007) is parked on the branch feat/wang-zhou-stage1, to be resumed after 1.0.0: pass 1 of its size and power study is done, and the Gaussian null cells at 5000 replications that decide it have not been run. The branch's wip/wang-zhou-stage1/README.md says how to resume. - Bai, Pan and Yin (2018), a test of homoscedasticity when the number of regressors grows with the sample. Li–Yao, below, is a different test for the same setting. - A design-based test of constant variance for survey data. The tests assume an independent sample with equal weights, and runSurveyHeteroTests() rejects a homoscedastic model 37% to 100% of the time when selection depends on the response or the sample is clustered. The natural candidates regress the squared residuals of a survey::svyglm() fit on the variance regressors using the design, as a Wald test (survey::regTermTest()) or as a Rao score test. Both were measured in inst/validation/survey-designs-size.R and neither clears the gate: 6% to 25% under the null at 300 and 1000 observations when the weights vary with the regressor. The variance estimate needs a small-sample correction first.

In another R package, not in this one

These add no test to R. They add it to the common interface, and each comes with a reference implementation to agree with.

  • Sign and size bias tests (Engle and Ng 1993): whether the squared residuals respond differently to negative and to positive lagged residuals. The time-series tests detect ARCH effects and say nothing about asymmetry. rugarch::signbias() computes the tests for a fitted GARCH model.
  • A break in the variance at an unknown date: the CUSUM of squares test (Brown, Durbin and Evans 1975), the statistic of Inclán and Tiao (1994), or the kappa tests of Sansó, Aragó and Carrion (2004), which stay valid under heavy tails and conditional heteroscedasticity. Goldfeld–Quandt and Szroeter need the ordering and the split to be chosen in advance. Reference implementation for the last: micss::kappa_test().
  • Simonoff–Tsai (1994) and Verbyla (1993). Versions of the Cook–Weisberg score test built on modified profile likelihood and on residual maximum likelihood, for small samples and high-leverage designs. Reference implementations: skedastic::simonoff_tsai() and skedastic::verbyla().
  • Bickel (1978) and Anscombe (1961). Tests of the variance against the fitted values. Bickel's is the robust one. Reference implementations: skedastic::bickel() and skedastic::anscombe().
  • Li–Yao (2019) for regressions with many regressors. performHighDimensionalTest() regresses the squared residuals on principal components and cites no heteroscedasticity test as its source. Reference implementation: skedastic::li_yao().
  • A spatial Breusch–Pagan test (Anselin 1988) for fitted spatial models. performSpatialHeteroTest() applies Moran's I to the squared residuals, which detects variance that clusters in space and does not test it against regressors. Reference implementation: spatialreg::bptest.Sarlm().
  • The rest of skedastic. Of its 25 tests, the package has counterparts for eight. Besides those named above, skedastic provides bamset(), carapeto_holt(), diblasi_bowman(), dufour_etal(), evans_king(), godfrey_orme(), honda(), horn(), rackauskas_zuokas(), wilcox_keselman(), yuce() and zhou_etal(). None is a priority by itself; each is a candidate when a user asks for it.

Order of work

The first group is where the package can contribute something R does not have, which is also what a reader of the article asks of it. Within that group:

  1. The modified Wald test. The panel functions exist, and it is the test Stata users look for under the name xttest3. Measured for 0.13.0 and not adopted, because it does not hold its level (above).
  2. The Dette, Neumeyer and Van Keilegom test. It removes the main limitation of performVarianceFormTest(), and is the most work of the two. Stage 1 done for 0.13.0: as the manuscript defines it, it does not hold its level when the variance is not constant (above).

Done

  • A Glejser test that is valid under skewed errors (Im 2000; Machado and Santos Silva 2000), in 0.13.0, as performGlejserTest(robust = TRUE). In the designs of inst/validation/glejser-skewed-errors.R the uncorrected statistic rejects a true null hypothesis 9% to 18% of the time at the 5% level when the errors are skewed, at every sample size, and skedastic::glejser() uses the uncorrected absolute residuals as well. The corrected statistic is the default since 0.15.0 (the 0.15.0 milestone, above).
  • Harrison–McCabe (1979), in 0.13.0, as performHarrisonMcCabeTest(), beside Goldfeld–Quandt and Szroeter. The 1979 article was not read; the definition rests on Harrison (1981), by its first author, whose published exact critical values the function reproduces, and on lmtest::hmctest(), with which the statistic agrees to 1e-8. The p-value is exact under normal errors given the regressors. Neither reference computes that p-value: lmtest::hmctest() simulates it from a regression on a constant alone, and skedastic::harrison_mccabe() 2.0.3 drops repeated eigenvalues and, with deflator, leaves the design in the order of the rows. In the designs of inst/validation/harrison-mccabe-size-power.R it holds its level under Gaussian errors, rejects 12% to 16% of the time under t5 errors as the other two ordered tests do, and has less power than either.

Decisions needed

Question Proposal Needed by
Ties in the ordering of performSzroeterTest() Average the ranks within ties and adjust the null variance for them. Today ties keep the order of the rows the model was fitted to, which holds the level but makes the statistic depend on that order when order_by is discrete, and for the registered test on a model whose design rows repeat, as with factors, binary or other discrete regressors. It changes the values the function returns. Since 0.16.0 the null mean and variance come from the design, and they hold for any weights: with averaged ranks a_i in place of i, h = sum a_i e_(i)^2 / sum e_(i)^2 has E h = tr(M A) / nu and Var h = 2 (nu tr(M A M A) - tr(M A)^2) / (nu^2 (nu + 2)) with A = diag(a_i), so the averaged ranks carry into the moments with no separate adjustment; rszroeter_moments(), which takes 1, ..., n today, would take the a_i 0.19.0
Supported R floor Test the floor in CI, or raise it to oldrel-1 0.18.0
The name of the companion package for the dashboard and the benchmark suite None recorded. Once it has one, launchDiagnosticDashboard(), run_benchmark_suite() and generate_benchmark_report() warn here for a minor release, naming it, and the code moves 0.19.0
A consistent test of the variance function after stage 1 Do not ship the Dette, Neumeyer and Van Keilegom test as defined: its prototype rejects a true non-constant variance function 0.019 to 0.036 of the time. Choose between a new stage 1 for a modified procedure (a better estimate of the variance function for the bootstrap data, or a studentized statistic) and one for Wang and Zhou (2007), and compare the published article with the manuscript first. If a test ships, a function of its own rather than performVarianceFormTest(against = "any") Before any stage 2
setup.sh / renv / Docker Keep Docker for reproducibility; drop setup.sh now CRAN is the install route 0.18.0
Register the tests now callable as (model, data) None recorded. Since 0.15.0 performArchLMTest(), performMcLeodLiTest(), performSpearmanTest(), performDavidianCarrollTest() and performHarveyTest() can be called as (model, data) with no further argument, which is when a test goes in R/diagnosticsRegistry.R; registering one adds a name runHeteroTests() accepts and changes the article 1.0.0