Skip to content

Audit fixes; release R 4.1.1, Python 2.1.1, Stata 11.1.1 - #25

Merged
scalonico merged 22 commits into
mainfrom
audit-fixes-4.1.1
Oct 7, 2026
Merged

scalonico merged 22 commits into
mainfrom
audit-fixes-4.1.1

Conversation

@scalonico

@scalonico scalonico commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Fixes from a three-language audit and follow-up reviews, incorporating the September changes already on main (PR #23). R 4.1.0 never reached CRAN but was public on GitHub with these bugs, so the CRAN version becomes 4.1.1. Python 2.1.0 and Stata 11.1.0 are already on PyPI and SSC, so they become 2.1.1 and 11.1.1.

Changes in results

  • Sharp RD with covariates and deriv >= 2: the point estimate now includes factorial(deriv) (R, Python, Stata). The SE already had it. With deriv = 2 the estimate was half its correct value.
  • Python: deriv >= 2 no longer crashes (UnboundLocalError: rdmodel).
  • rdbwselect() with stdvars = TRUE (the default since 4.1.0): reports the effective sample size, not the full sample, and the cutoff on the original scale (R, Python). Senate data: N_h 595/702 → 360/323, matching rdrobust().
  • R: a fuzzy design with sharpbw = TRUE and a nonzero cutoff no longer crashes.
  • rdplot(): bin edges stay with their own bins when a side has empty bins (R, Python). In Stata, genvars describe the estimation sample, and per-bin CIs use N - 1 degrees of freedom, as in R and Python.
  • Bandwidth selection: the bwcheck floor and the first pilot bandwidth (h_B = range) are padded by 1 + sqrt(eps), so the observation that sets them keeps a positive kernel weight (all three). Selected bandwidths move by less than 1e-6 relative on the Senate test grids (Senate default h: 17.75439722 → 17.75439819). A cost-sharing replication has bandwidth and standard-error movements of about 2e-6 relative; fixed-bandwidth baseline results are unchanged.
  • R: default ginv.tol is 1e-15 (was 1e-20), as in Python and Stata. A covariate nearly collinear on one side of the cutoff made the selected h depend on row order (2e-3 on the Kuo data, default options); that case is fixed. A pre-existing dependence of about 1e-3 remains with vce = "hc0" to "hc3" and masspoints = "off" in near-collinear designs.

Errors and warnings

  • Unidentified bandwidth pilots (a pilot window with fewer distinct x than polynomial coefficients, e.g. a running variable with a few values): all three now stop with "Not enough variability". Before, R returned a bandwidth from ginv(), NaN or an unrelated error (svd, "missing value where TRUE/FALSE needed"); Python raised LinAlgError; Stata printed a message and continued, usually with the full range. The rule counts distinct values, so the answer is the same on every platform. The author reported agreement across R, Python and Stata on a grid of 60 discrete designs (57 errors, 3 identical bandwidths).
  • A side with fewer than q + 1 distinct values of x (p + 1 in rdbwselect()) stops early with the reason; one observation on a side used to crash.
  • Clustered SEs: count clusters with positive combined weight within h; warn with fewer than 10 per side, with a stronger caution at p + 1 or fewer. The warning allows for unreliable inference or a degenerate variance without asserting that cluster count alone makes the scalar RD variance unidentified. Stata respects nowarnings.
  • Input validation: R and Python reject negative weights and noninteger/nonpositive nnmatch, and rdbwselect() accepts the same option casing as rdrobust(). The Python bandwidth normalizer retains ValueError. R gives an informative error for collinear covariates with covs_drop = FALSE; Python retains its linear-algebra error there. Stata retains its earlier handling of nonpositive weights and rejects nonnumeric nnmatch tokens before they reach Mata.
  • Stata: e(sample) is set, user matrices and tempvars are no longer overwritten, error codes are fixed, and rdrobustplot accepts twoway options. Unidentified bandwidth pilots return r(2001).

Compatibility

The September N < 20 behavior is retained: supplied estimation and bias bandwidths are respected, rho takes precedence, and fallback bandwidths remain in the original running-variable units.

Checks

Checks repeated after the final warning correction:

  • R: 122 test blocks, 368 expectations pass. Linux R 4.5.2 R CMD check --no-manual --no-build-vignettes: Status: OK.
  • Python: 58 package tests pass against the built 2.1.1 wheel; 126 tests pass when combined with the retained local regressions. Source/wheel builds and twine check pass.
  • Stata 16: all 23 retained regressions pass with the shipped lrdrobust.mlib. The general-cluster example agrees with an independent CR1 calculation, emits the corrected warning, and respects nowarnings.
  • Retained R regressions: 24 small-sample cases and 25 follow-up cases pass. The local empty-window assertion accepts the improved diagnostic.
  • General-cluster regression cases in R and Python distinguish a positive scalar variance from the saturated mass-point example whose conventional SE is near zero.

Numerical comparisons repeated on fa3fea8, before the warning-only correction: all 146 R/Python baseline fields and 108 R/Stata numeric fields agree at absolute/relative tolerance 1e-6; fixed-bandwidth baseline results are unchanged from main in all three languages. Twelve selected Python replication cases succeed on both versions. Independent Stata checks also cover exact rho() arithmetic, user namespace preservation, e(sample), repeated plotting, graph options, and genvars.

The earlier Windows --as-cran, 13 reverse-dependency checks, 60-design discrete grid, and full 85-estimation/150-selector parity grids were reported by the author and were not repeated in this final review.

The Stata library and help PDFs supplied in the PR are retained. The final warning correction changes no estimation formulas. Submission to CRAN, PyPI, and SSC will be handled by Sebastian after merge.

scalonico and others added 22 commits August 13, 2026 12:05
Closes audit items XL-1 (R half), R-2a/2b, R-3, R-4, R-5, R-8, R-9, R-10,
R-12, R-17 (partial), NEW-1, NEW-2, NEW-5 from
AUDIT_FINDINGS_rdrobust_2026-07-20.md.

- XL-1: restore the missing factorial(deriv) in the sharp+covariates branch,
  in the tau block *and* in beta_Y_p_l/r, which had the same omission. Every
  other branch already carried it, so fixing tau alone would have left the
  package internally inconsistent. At deriv=2 the estimate was half the truth.
- R-2: the effective-N block compared STANDARDIZED X_l/X_r/c against a
  bandwidth already restored to the original scale, so every observation fell
  inside the window and the full sample was reported (613/587 vs 161/134).
- R-3: the restore block put c, X_l/X_r and Y_l/Y_r back on the original scale
  but not x and y, so the fuzzy re-split compared two different scales and
  crashed whenever c != 0.
- R-4: the N<20 fallback returned a bandwidth still on the standardized scale.
- R-5/R-9/R-12: normalize string options before validating, so "NN"/"TRI" and
  friends no longer silently take a different code path.
- R-10/NEW-2: validate nnmatch and reject collinear covs under covs_drop=FALSE
  instead of surfacing a raw LAPACK message.
- NEW-1/NEW-5: guard degenerate bandwidths and empty-side windows rather than
  failing later with "missing value where TRUE/FALSE needed".
- R-8: document that vcov() off-diagonals are placeholder zeros; the design
  matrices needed for the cross-covariances are not retained on the fit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes PY-1, PY-2a/2b, PY-4, PY-7, R/PY-12, NEW-1 and the Python half of
XL-1 from AUDIT_FINDINGS_rdrobust_2026-07-20.md.

- PY-1: sharp/fuzzy deriv>=2 always raised UnboundLocalError after all the
  computation had completed -- the deriv>=2 branch of the rdmodel assignment
  was dropped in the port from R. Rewritten to mirror rdrobust.R:834-852.
  With this, the 3-language baseline goes from 72/85 to 85/85 computable
  specs with zero movement in previously-working cells.
- PY-4: the N<20 fallback called np.max(range_l, range_r), which passes
  range_r as the AXIS argument and raised TypeError every time it fired.
- PY-2: same standardized-vs-raw effective-N bug as the R side.
- PY-7: rdplot(kernel="TRI") estimated triangular but labelled the output
  Uniform.
- Shared option normalizer/whitelist so casing no longer changes behaviour.

Note: the njit fallback in funs.py must stay inside its except ImportError
block; an earlier edit orphaned it after a raise, which was latent only
because numba happened to be installed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r codes

Closes ST-1, ST-2, ST-3, ST-4, ST-5, ST-7, ST-8 and the Stata half of XL-1
from AUDIT_FINDINGS_rdrobust_2026-07-20.md.

- ST-7 (user namespace destruction): st_matrix("b"/"V") created GLOBAL
  matrices literally named b and V, and they were dropped afterwards, so a
  user matrix of either name was gone for good; a user scalar rho was
  clobbered too. Now tempname bmat/Vmat plus a local for rho. Note there were
  three further bare references (matrix colnames b / rownames V / colnames V)
  that only surfaced AFTER the results table printed.
- ST-2: dups/dupsid were declared as tempvars but gen'd and read under their
  LITERAL names, so a user variable of either name broke every default
  vce(nn) run behind a misleading rc=3499.
- ST-1: M_l/M_r were set only inside the auto-bandwidth branch, so
  h() + masspoints(check) died rc=111 -- or, after a previous run, silently
  displayed stale counts.
- ST-8: rho() silently overrode an explicit b(), and a positive rho below
  5e-5 rounded to zero and was ignored.
- ST-3: stdvars(ON) and other non-lowercase values silently behaved as off,
  defeating the scale default (verified 43x bandwidth difference).
- ST-4: Mata exit(1) -> exit(506); rc=1 is Stata's --Break--, so under qui the
  user saw only "--Break--" with no hint of the cause.
- ST-5: rdbwselect printed a blank cutoff because `c_orig' was never defined.
- XL-1: the missing factorial(deriv) in the sharp+covariates branch.

Only the first token of vce() is lowercased: tokens 2-3 are a cluster
variable name and an nnmatch count, and Stata variable names are case
sensitive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The package shipped without tests. This adds 110 testthat tests covering the
behaviours the 2026-07 audit found broken, so they cannot silently regress:
bandwidth selection, covariates, fuzzy, scale invariance, mass points,
numerical contracts, point estimates, print/summary, rdplot, input
validation, variance, clustered vce, and weights.

test-known-bugs.R is the deliberate home for defect witnesses: each test
either asserts the fixed behaviour or is skipped with the open defect ID, so
the file doubles as a live checklist. Wires up testthat edition 3 in
DESCRIPTION.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ypos

Closes ST-6, ST-9 and ST-10 from AUDIT_FINDINGS_rdrobust_2026-07-20.md.
3-language baseline re-run after these edits: 85/85 + 150/150 in all three
languages, 0 discrepant cells.

ST-6. `touse' came from marksample and marked the [if]/[in] sample only, while
every missing-value drop happens inside the work frame -- so e(sample) claimed
rows that never entered the estimation (1390 marked vs e(N)=1244). The
drop_cond local already gathers every condition in one place and is written
over variables that exist in the caller's frame, so it is re-applied to touse
just before ereturn post. e(sample) == e(N) in 4/4 configurations: plain
(2740), covs (2670), vce(cr1 cl) (2690), and under an if (1840). Same
one-line approach as rdhte ST-8.

ST-9. rdplot is eclass and therefore clears e(), so after one rdrobustplot the
"must follow rdrobust" guard failed and a SECOND call died rc=301. The
estimates are now parked with _estimates hold across the delegation and
restored afterwards, with rdplot's rc captured and re-raised in between so a
genuine failure still surfaces. Three consecutive calls now all return 0,
e(cmd) is still rdrobust afterwards, and the guard still fires rc=301 after an
intervening regress.

Of the seven accepted-but-dead options, five -- scale, xtitle, ytitle, xlabel,
ylabel -- are ordinary twoway options and are now folded into the
graph_options() handed to rdplot, so they take effect. scale was the most
misleading: its only appearance was a no-op self-assignment. col_dots() and
col_lines() are removed instead: they are per-plot marker and line colours and
rdplot builds its own twoway call with no hook for them, so they could never
have worked; col_dots(red) now returns 198 rather than being ignored.

ST-10. rdplot's trailing `*' collected unknown options into `options', which
the ado never reads (0 references) -- so a typo was accepted and silently
ignored while rdrobust and rdbwselect correctly rc=198. Removed; twoway
options keep their own graph_options() slot.

A genvars rerun now names the collision and the drop command instead of a bare
rc=110. Deliberately NOT a blanket `capture drop rdplot_*' -- that is the
destructive pattern fixed in nprobust lprobust.ado ST-12. A user variable
rdplot_myvar sharing the prefix survives untouched while genvars completes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes ST-13 from AUDIT_FINDINGS_rdrobust_2026-07-20.md. 3-language baseline
after these edits: 85/85 + 150/150 in all three languages, 0 discrepant cells.

rdrobustplot.sthlp is rewritten to match the command after ST-9: xlabel() and
ylabel() added to the syntax; col_dots() and col_lines() removed, with a note
saying plainly that they never had any effect and are now rejected rather than
ignored; and the claim that "any other option is passed through to rdplot's
graph_options()" corrected -- extras go straight to rdplot and must be valid
rdplot options. A new Stored results section documents the six r() values the
command sets (tau, se_rb, ci_l, ci_r, pvalue, subtitle), which the help had
never mentioned, and states the repeatability guarantee.

masspoints "false": the finding held for rdplot only. rdrobust.ado and
rdbwselect.ado already reject it at a strict earlier gate, which made their
later lax inlist(..., "false") checks unreachable dead code that merely looked
like a hole. Removed from all three so the two gates agree. masspoints(false)
is now rejected everywhere, while off/check/adjust are unaffected.

rdrobust.sthlp: rho() precedence over b() is now documented (the ST-8 note),
verified by h(0.3) b(0.5) rho(2) giving e(b_l) = .15 = h/rho. e(sample) is
documented now that ST-6 made it truthful -- it marks the rows surviving both
if/in and the listwise deletion, so count if e(sample) equals e(N). The e(N)
line still said "original number of observations"; it has been post-deletion
since the extended drop scan, and now says so.

Also confirms the ST-10 wildcard removal propagates: a bogus option passed
through rdrobustplot is now caught by rdplot rather than silently swallowed,
while a valid passthrough such as masspoints(off) still works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes PY-5 from AUDIT_FINDINGS_rdrobust_2026-07-20.md.

R bins with findInterval(x_r, jumps_r, rightmost.closed=TRUE), whose intervals
are LEFT-closed and right-open -- jumps[i] <= x < jumps[i+1] -- numbered
1..J_star_r. searchsorted(side='left') is the opposite convention,
(jumps[i-1], jumps[i]], so two things went wrong at once: an interior point
sitting exactly on a bin edge was assigned one bin too low, and the numbering
ran 0..J_star_r -- J_star_r+1 distinct values, with bin 0 holding only the
minimum -- so the old clamp folded J_star_r into J_star_r-1 and MERGED the
outermost two bins.

side='right' reproduces findInterval exactly, including its 1-based numbering;
only the rightmost.closed adjustment is still needed, and it becomes
J_star_r+1 -> J_star_r. The left side already used side='right' and was
correct, so only the right-side line changes.

Verified against R on the shipped senate data at nbins = (10,10), (17,17),
(20,20) and (5,30): row counts now identical and every column (mean_bin,
mean_x, mean_y, min_bin, max_bin, N) agrees to 2.22e-16. Before the fix Python
produced exactly one bin fewer than R in all four specs (19/20, 31/32, 38/39,
33/34), confirmed by stashing the change so the check is not vacuous.

Worth recording: bin counts alone are not a correctness criterion, since empty
bins legitimately disappear in both languages. The comparison has to be
against R rather than against the requested nbins.

rdrobust Python 3/3, R suite green, 3-language baseline 85/85 + 150/150 with 0
discrepant cells.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rashing

Fixes the genvars rc=3301 crash found on 2026-08-13 while working ST-10. It
was pre-existing, not a side effect of that change.

PART 2 (genvars) runs after cwf back to the caller's frame and after the work
frame has been dropped, so its `touse' is still the raw [if]/[in] sample: the
missing-value drops only ever happened in the work frame. Two consequences.

(a) genvars attached bin statistics to observations that never entered the
estimation -- on the shipped senate data, rows with a missing outcome but a
valid running variable were given a fitted value.

(b) the loop's only guard is ZZ[i,1] != ., which skips a missing x but not a
missing y. Such a row still has a valid x, so it gets binned; if it lands in a
bin holding no RETAINED observation then select() returns 0 rows and the 1x10
assignment aborts the whole command.

That combination is why the trigger looked erratic: it needs both a
dropped-but-x-valid row and an empty bin, so missing-y-only or missing-x-only
data frequently passed. Instrumenting the loop on the repro showed
rows(ZZ)=3000 rather than the 2740 analytic rows, 418 bins with rows(rdplot)
=417 -- one empty -- and row 50 (missing y, x=.304) mapping to that empty bin.

Fixed by narrowing touse with the same drop_cond the work frame used; the
local survives the frame switch, which is the technique already used for ST-6
here and ST-8 in rdhte. Added a defensive rows(_sel) == 1 guard as well, since
an empty bin is legitimately possible with mass points or an extended
support(): the bin columns are left missing rather than aborting.

Verified on rdrobust_senate: 1390 rows, 1297 complete cases, rdplot_hat_y
filled for exactly 1297, zero rows filled outside the analytic sample, zero
analytic rows left empty. The original repro plus the missing-y, missing-x and
missing-covs variants all return rc=0; ST-10's genvars rerun and
prefix-safety checks still pass; rdplot R tests green; 3-language baseline
85/85 + 150/150 with 0 discrepant cells.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…off outside x

Closes R-6 and the R half of PY-11 from AUDIT_FINDINGS_rdrobust_2026-07-20.md.

rdplot's vars_bins carried a fossil index. Line 507 had already been fixed to
address the left-side bin arrays as rdplot_bin_l + J_star_l + 1, but the
min_bin/max_bin assignment 56 lines further down still used the older
rev(-rdplot_bin_l) form. The two agree only when every left bin is occupied.
Empty bins are legitimately dropped from vars_bins, so with an asymmetric
pattern of holes rev() reverses a list that is no longer symmetric and each
surviving left row gets the edges of a different bin -- min_bin and max_bin
that do not bracket its own mean_x. The right side was never affected.

Both languages carried it: Python/rdrobust/rdplot.py:833-834 is a verbatim
port of the same expression and is fixed in the companion Python commit.

Verified on a fixture whose left support has a gap in [-0.55, -0.25], so 12
requested left bins produce fewer occupied ones: before the fix mean_x fell
outside [min_bin, max_bin] for the affected rows and the midpoint disagreed
with rdplot's independently computed mean_bin; after, all three agree to
1e-12. The test asserts the gap actually emptied bins, so it cannot pass
vacuously; reverting the fix via git stash fails it.

rdbwselect: the c-within-range check at what is now line 215 was unreachable
in the case it exists for. With c outside the support, one side is empty and
the masspoints block above it computes mass_l/mass_r as 1 - M/N with N = 0,
so the function dies at `if (mass_l >= 0.2 | ...)` with "missing value where
TRUE/FALSE needed" -- conditionCall() confirms the site -- before the
informative message can fire. Moved a copy of the test to line 142, ahead of
the masspoints work. This is the same gap the Python front-end had, which is
why one audit item touched both languages.

rdrobust R suite 312 pass / 0 fail / 0 skip. Numbers unchanged: 16 stashed
before/after cases bit-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…indexing

Closes PY-11 and the Python half of R/PY-6 from
AUDIT_FINDINGS_rdrobust_2026-07-20.md.

rdplot.py:833-834 is a verbatim port of the R expression fixed in the previous
commit: rdplot_min_bin_l[np.flip(-rdplot_bin_l)-1] reverses a left-bin list
that is no longer symmetric once empty bins have been dropped, so surviving
left rows get another bin's edges. Replaced with the direct index
rdplot_bin_l + J_star_l, matching R.

The rest is validation R has always had and Python did not, so the same call
errored in one language and returned numbers in the other:

- h and b were unchecked. A negative or NaN bandwidth reached the Cholesky
  factorization as a degenerate design and surfaced as "LinAlgError: Internal
  potrf return info = [1]"; a length-3 h matched neither the scalar nor the
  length-2 branch, so estimation continued with h_l never assigned and the
  user saw "UnboundLocalError: cannot access local variable 'h_l'" -- an
  internal-logic error presented as their error.
- Negative weights were swept into the NA mask and dropped silently. With the
  negatives concentrated on one side that emptied a whole side of the cutoff
  and the user was told "c should be set within the range of x": an error
  about the cutoff, for a weights mistake.
- All-zero weights gave a design matrix of zeros and another LinAlgError.
- rdbwselect never checked that c lies inside the support of x, though
  rdrobust does; the reductions below it died as "zero-size array to reduction
  operation minimum", naming neither c nor x.

Messages mirror R's (rdrobust.R:93-99, 312-328). New tests/test_validation.py
covers all four with 8 test functions; the bin-edge case joins
test_numerical_contracts.py as the Python twin of the R test.

rdrobust Python 19 passed (was 3 before the Mac session added these).
Behaviour change worth a release note: calls that used to return numbers with
a bad h/b or negative weights now raise, matching R.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes R-8 from AUDIT_FINDINGS_rdrobust_2026-07-20.md, which was resolved by
documenting rather than by filling in the matrix.

vcov() returns the squared standard errors of the Conventional, Bias-Corrected
and Robust rows with zeros off the diagonal. Those zeros are placeholders, not
estimated covariances and not a claim of independence: the three rows are
different estimators of the SAME parameter and are strongly correlated, but
the design matrices behind tau_cl and tau_bc are not retained on the fitted
object, so the cross-covariances cannot be recovered after the fact.

The source has said so since 2026-08-13 and the returned matrix carries an
"offdiag" attribute, but nothing user-facing did -- there is no man page for
the method, so `vcov(m)` handed back a matrix whose most important property
was invisible unless you read the attribute or the source. A user combining
rows from it, for instance Wald-testing Conventional against Robust, would be
treating two highly correlated numbers as independent and would get a wrong
answer with no warning.

Adds coef.rdrobust and vcov.rdrobust aliases so `?vcov.rdrobust` resolves, and
a section stating what the off-diagonals are, why they cannot be filled in,
and what to use instead (object$se / $ci / $pv per row, and the Robust row for
reporting).

Documentation only; no code path changes. checkRd clean; rdrobust R suite 312
pass / 0 fail / 0 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rdrobustplot's trailing wildcard forwarded unrecognized options to rdplot
as top-level options. rdplot silently swallowed them before ST-10 removed
its own wildcard, and rejects them rc=198 after -- so legend(off) etc.
never had a working route. Two explicit routes now: rdplot ANALYSIS
options (masspoints, covs_drop, covs_eval, support, genvars, nochecks,
precision -- the contract the sthlp already documented) are declared by
name and forwarded as such; everything else, plus a new explicit
graph_options(), folds into the graph_options() handed to rdplot, which
appends it after its own twoway settings so later-wins applies. A typo'd
option now errors from -twoway- instead of vanishing. Verified on Stata
16.1: plain/legend(off)/graph_options()/masspoints(off)/legend+xline all
rc=0, typo rc=198, repeat call rc=0 (audit/scratch/fc17/rdrp_check.log).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Conflicts in the N < 20 fallback of R rdrobust.R and Python rdrobust.py,
which both sides rewrote: kept main's version (it respects supplied h, b and
rho and uses original units), which also covers the audit fix there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ELOG

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er warnings

All three languages unless noted.

- Bandwidth pilots: a pilot window with fewer distinct x than polynomial
  coefficients (or an undefined pilot from the previous stage) is not
  identified. It now leads to "Not enough variability" in R, Python and
  Stata. Before, R returned a bandwidth from ginv(), NaN or an unrelated
  error; Python raised LinAlgError; Stata printed a message and went on,
  usually with the full range as bandwidth. The rule counts distinct values,
  so it gives the same answer on every platform; identified fits keep their
  inversion (Cholesky, generalized inverse as fallback in all three).
- D1: pad the bwcheck floor and the first pilot bandwidth (h_B = range) by
  (1 + sqrt(eps)), so the observation that sets them keeps a positive kernel
  weight. Selected bandwidths move by less than 1e-6 relative.
- D2 (R): default ginv.tol 1e-15 instead of 1e-20, as numpy's pinv and
  Stata. A covariate nearly collinear on one side no longer makes the
  selected bandwidth depend on row order or noise.
- D3: warn when either side has fewer than 10 clusters within h, and when
  a cluster-robust standard error is exactly 0.
- R-17: fail early, with the reason, when a side has fewer than q+1 distinct
  values of x (p+1 in rdbwselect); a single observation used to crash.
- ST-16 (Stata): rdplot per-bin CIs use N-1 degrees of freedom, as R/Python.
- Stata: lrdrobust.mlib rebuilt with Stata 16.1. The new exits sit where
  Stata surfaces their return code (2001).

Tests: R 116 (new test-post-campaign.R), Python 37 (new
test_post_campaign.py); Senate baselines updated for D1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
R 4.1.0 never reached CRAN but was public on GitHub with the bugs fixed
here, so the CRAN version becomes 4.1.1. Python 2.1.0 and Stata 11.1.0 are
already on PyPI and SSC. CHANGELOG entry rewritten for the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Made with Stata's translate (smcl2pdf), as before. Picks up the August help
corrections, mainly rdrobustplot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The package now suggests testthat for its test suite, and R CMD check
stops when a suggested package is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Matias's review of 3f9ac7d (ISSUES.md):
1. Stata: b = h/rho uses the supplied rho, not round(rho, .0001)
   (rho=.00014 gave b=5000 instead of 3571.43; positive rho below 5e-5 was
   rejected). rho() still takes precedence over b(); a user scalar rho
   survives.
2. Python: rdbwselect checks every returned selector row, so all=True no
   longer returns NaN rows; mserd alone still works on the same data.
3. R and Python: the few-cluster warning counts clusters after the user
   weights, so zero-weight observations do not count.
4. Python: drop the duplicate h/b precheck; the September
   _normalize_bandwidth already raises ValueError (complex values too). The
   early insufficient-support stop raises ValueError.

Second review of the full diff against main:
- Python: msecomb1/cercomb1 used the built-in min(), which hid an undefined
  msesum pilot behind mserd; np.min passes the NaN to the guard (R and
  Stata already stopped).
- R: the distinct-value stop ran before the cutoff check, so a cutoff
  outside the data was reported as a support problem; it now runs after.
- R: drop a duplicate masspoints check in rdbwselect that rejected "",
  which main accepts.
- R and Python: rdbwselect rejects negative weights and an nnmatch that is
  not a positive integer, as rdrobust does (R: nnmatch=0 crashed on 0/0;
  Python accepted 2.5 and gave a misleading error for 0).
- R: vcov.rdrobust no longer adds an "offdiag" attribute (documentation of
  the diagonal-only contract is kept).
- Clustered SEs: "exactly zero" never fired (the SE is ~1e-15, not 0). The
  warning now uses the deterministic condition instead: p+1 or fewer
  clusters on a side, where the CR variance is not identified (R, Python,
  Stata). Stata's new warnings respect nowarnings.
- Stata: rdrobustplot passes nochecks (stored in `checks'); rdplot and
  rdrobustplot accept unused options and col_dots()/col_lines() again, as
  main does, with a note that they are ignored (main ignored them silently;
  the earlier version of this PR made them errors).
- Python __pycache__ files are no longer in the branch.

Tests: R 121, Python 56; the new ones fail on the previous version.
CHANGELOG states which checks apply to which functions and languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Python: negative weights raise ValueError in rdrobust() and rdbwselect(),
  as the CHANGELOG states (they raised a bare Exception).
- Python: a single cluster on a side gives NaN standard errors, as in R,
  instead of a ZeroDivisionError in the CR1 factor g/(g-1).
- Stata: a non-numeric nnmatch token in vce(nn ...) exits 125 with a message
  (vce(nn cl) used to die inside Mata with "cl not found"); rdrobust and
  rdbwselect.
- CHANGELOG: scope the row-order statement for ginv.tol to the verified case
  (Kuo data, default options); the hc*/masspoints("off") path keeps a ~1e-3
  dependence in near-collinear designs, as in 4.0.0. Entries for the two
  fixes above.
- Python tests: expect ValueError; new single-cluster test (57 pass).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@scalonico
scalonico requested a review from mdcattaneo October 7, 2026 21:59
@scalonico
scalonico merged commit 7df2393 into main Oct 7, 2026
3 checks passed
@scalonico
scalonico deleted the audit-fixes-4.1.1 branch October 7, 2026 22:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants