
Backend Contracts: fast_* and C++ Utilities
Source:vignettes/backend-contracts.Rmd
backend-contracts.RmdEDI’s statistical model-fitting is implemented once, in C++, under
R/EDI/src/, and exposed twice: to R via Rcpp
(fast_*_cpp exports) and to Python via pybind11
(edi_kernels, same argument names minus the
_cpp suffix, and 0-based instead of 1-based indices — see
below). This page documents the conventions and guarantees (and, just as
importantly, the non-guarantees) that hold across essentially every one
of those backend functions, so individual function docs can link here
rather than repeating “not validated by this function” verbatim on every
page. See vignette("notation-glossary") for symbol meanings
and vignette("reproducibility") for RNG/seed conventions
specifically.
The validation boundary: R6 wrapper vs. raw backend
Validation happens in the R6
Design/Inference layer, not in the
_cpp backend it calls. A public method like
InferenceContinOLS$compute_estimate() runs
checkmate assertions (gated by
should_run_asserts()/toggle_asserts()) on its
own arguments before calling into a fast_*_cpp
function — dimension checks, type checks, range checks, NA checks. The
backend function itself then does essentially none of that: it trusts
the shapes and values it receives. This means:
-
Calling a raw
fast_*_cpp/edi_kernels.fast_*function directly (bypassing the R6 class layer entirely — legitimate for performance- sensitive code, and the whole point of the Python bindings) skips the R6 layer’s validation. Malformed input (wrong dimensions, non-finite values where they’re not expected, an unsorted or malformed censoring structure) will not be caught with a clear error message; the most likely outcomes are silently wrong results, an opaque linear-algebra failure (e.g. a Cholesky/LDLT decomposition throwing on a non-positive-definite matrix), or, in the worst case, a segfault. Individual function docs that say “not validated by this function” are describing exactly this boundary, not an oversight. -
One documented, deliberate exception:
fixed_idx/fixed_values(the optional-parameter-fixing mechanism shared by nearly every iterative fitting backend) is validated at the C++ level, bymake_fixed_param_spec()(_helper_functions_core.h): it checksfixed_idxentries are in-range one-based indices, rejects duplicates, and requiresfixed_valuesto be finite and the same length asfixed_idx— throwingstd::invalid_argument(surfaced as an R/Python error) rather than silently misbehaving. This one mechanism is validated everywhere specifically because a silently-wrong fixed-parameter index would corrupt every downstream coefficient, not just one.
Argument dimensions and storage order
-
Design matrices are passed as
Eigen::Map<const Eigen::MatrixXd>orEigen::Ref<const Eigen::MatrixXd>, \(n\) rows \(\times\) \(p\) columns. Whether an intercept column is expected to already be included in \(X\) is function-specific, not a package-wide rule — e.g. Cox regression (fast_coxph_regression) never takes one (the partial likelihood has no intercept), while most GLM-style fitters (fast_ols,fast_logistic_regression,fast_poisson_regression, …) expect the caller to add one if wanted; the function’s own parameter doc states which. -
Column-major storage. Eigen’s default matrix
storage is column-major, which matches R’s native matrix storage exactly
— this is why so many
_cppexports takeEigen::Map<const Eigen::MatrixXd>directly on an RREALSXP’s data pointer: it is a genuine zero-copy view, not a conversion. NumPy’s default array order is row-major (C order); passing a row-major NumPy array across the pybind11/Eigen boundary to a parameter typedEigen::Ref<const Eigen::MatrixXd>can therefore incur a silent copy (pybind11/Eigen handles the conversion transparently, but not for free) — pass a Fortran-ordered (order="F") array if avoiding that copy matters for a hot path. -
1-based vs. 0-based indexing. Every index-valued
argument that identifies a column of a coefficient/design
matrix (
j_treat,j_T,fixed_idx) is 1-based on the R/Rcpp side (matching R’s own 1-based vector/matrix indexing) and 0-based on the Python-binding side (matching Python/NumPy convention) — this is a deliberate per-language adaptation, not an inconsistency, and is stated explicitly on every such parameter. Ordinal-response category coding (yvalues \(1,\dots,K\)) is always 1-based in both languages, since it labels categories rather than array positions. -
Vector length agreement is generally assumed, not
checked. Most backends do not verify that
yhas length \(n\) matchingX’s row count, thatweights/fixed_valuesmatch their paired index vector’s length (the one exception beingfixed_idx/fixed_values, checked as above), or that agroup_id/stratavector’s length matchesX. Passing mismatched lengths typically reads out-of-bounds or silently truncates rather than erroring cleanly.
Numeric domains, overflow, and underflow safeguards
Domain assumptions are almost never checked at the backend level (see “Validation boundary” above) but the numeric kernels themselves do guard against the specific overflow/underflow failure modes their own formulas are prone to:
-
fast_log1pexp(x)(softplus): for \(|x| \le 37\), uses a numerically stable atanh-series identity rather than the naivelog(1+exp(x)), which would overflowexp(x)for large positive \(x\) long before the true (finite) log-sum-exp value does; outside that range it returns the exact asymptotic value (xitself, orexp(x)) directly. -
pnorm_fast(x)/fast_log_pnorm(x): clamped to \([6\times10^{-16}, 1-6\times10^{-16}]\) (respectively \([-35.05, -6.6\times10^{-16}]\) on the log scale) for \(|x| \ge 8\), so an extreme linear predictor cannot produce an exact \(0\) or \(1\) probability that would later become a-Inf/NaNwhen log-transformed or divided by downstream. -
logit()/inv_logit()(other_helpers.R): both clamp their probability argument/result to \([\texttt{zero\_one\_logit\_clamp}, 1-\texttt{zero\_one\_logit\_clamp}]\) (default.Machine$double.eps) before/after transforming, for the same reason. -
EDI_SEPARATION_THRESHOLD(globals.R, \(10^6\)). A coefficient magnitude beyond this is treated package-wide as evidence of complete/ quasi-complete separation (the MLE does not exist, or a bootstrap/ likelihood-ratio replicate has diverged) rather than a genuine large estimate —is_separated_coefficient_magnitude()centralizes a check that used to be copy-pasted independently across several files. (One documented exception:inference_mixin_kk_gee_shared.R’s GEE family uses its own, deliberately tighter, threshold of \(10^4\) for the same purpose.) -
fast_erfc(x): uses a Cephes piecewise rational approximation for \(|x| \le 5.6\) and falls back to the platformlibmerfcbeyond that, specifically to preserve accuracy in the extreme tail outside the range the rational approximation was fit against.
None of these are “input validation” in the sense of rejecting bad input — they are numerical safety nets that keep a function returning a finite, sane value under the extreme arguments its own optimizer or a diverging model fit can legitimately produce, as distinct from the (mostly absent) checking of whether the input made statistical sense in the first place.
Convergence flags and return-object conventions
Nearly every iterative-fitting backend returns some variant of the
same small set of fields — edi::ResultMap’s
to_rcpp_list()/to_py_dict() converts whatever
fields a given backend .set()s into an R
list() or Python dict, so the
available fields differ by function, but their
meaning, where present, is package-wide:
| Field | Meaning |
|---|---|
converged |
Logical; whether the optimizer’s stopping criterion was met before
maxit was reached. FALSE does not necessarily
mean the returned coefficients are useless — it means the convergence
tolerance was not certified met, so downstream code should decide
whether to trust, retry (e.g. robust_survreg’s
random-restart loop), or reject the fit. |
iterations |
Integer count of optimizer iterations actually taken. |
gradient_norm |
The norm of the score/gradient at the returned parameter vector — a
continuous convergence diagnostic independent of the boolean
converged flag; useful for distinguishing “essentially
converged, tolerance was just slightly too tight” from “genuinely still
moving.” |
neg_loglik / neg_ll /
loglik
|
The negative log-likelihood at the fit
(neg_loglik/neg_ll are aliases for the same
quantity — both are populated so callers used to either naming
convention find it) and, where included,
loglik = -neg_loglik computed only when finite
(NA/omitted otherwise) as a convenience so callers don’t
need to negate it themselves. |
vcov, std_err, z_vals
|
The parameter covariance matrix, per-parameter standard errors
(sqrt(diag(vcov))), and Wald z-statistics
(coef / std_err) — present only on backends that were
called in variance-computing mode (see estimate_only
below). |
fisher_information / observed_information
/ information
|
Three names for the same matrix on backends that
expose it (e.g. fast_ordinal_regression_with_var_cpp,
fast_cpoisson_combined_with_var,
fast_hurdle_negbin_with_var) — populated once, aliased
under all three names, since different call sites in the codebase
historically settled on different names for the identical quantity. |
estimate_only (an argument, not a return field) |
When TRUE, skips computing the
Hessian/Fisher-information-derived quantities above entirely (not just
omitting them from the return value) — a real performance path, used
e.g. inside bootstrap/randomization inner loops that only need a point
estimate per replicate, not a full covariance matrix. |
Shared cores and wrapper-to-backend equivalence
Two structural patterns guarantee that “the R version” and “the Python version” of a given kernel are not just similar but the exact same compiled logic:
-
EDI_CORE_ONLYportable headers. Files likefast_erfc.h,_helper_functions_core.h,ordinal_fixed_link_helpers.h, andfast_gamma_functions.hare written to compile with or without Rcpp, guarded by#ifdef EDI_CORE_ONLY(a plainconstexpr double NA_REAL = ...substitute for R’sNA_REALwhen compiled standalone). The Python bindings (python/cpp/) compile directly against these same header files fromR/EDI/src— nothing underpython/is a copy of package logic; it is the identical source, recompiled into a different extension module. -
*_internal()shared-core functions. Where a scalar/vector algorithm needs both an R-facingRcpp::NumericVector-typed export and a Python-facing plain-struct-returning version, the actual logic lives once in a..._internal()function operating on plain doubles/structs (e.g.wilson_score_interval_internal(),newcombe_independent_ci_internal(),mn_ci_internal()), and both the[[Rcpp::export]]wrapper and the pybind11 binding call that same internal function — so an R user and a Python user calling the “same” function with the same inputs are provably calling the same compiled code path, not two independently maintained reimplementations that merely intend to agree. -
The portable RNG.
edi_rng::RRng(seevignette("reproducibility")) is the same pattern applied to random-number generation specifically: a from-scratch, dependency-free reimplementation of R’s own Mersenne-Twister generator, so a seed produces bit-identical draws whether the call originates in R or in Python.
NA/NaN handling
There is no single package-wide rule for what a backend does when it
receives NA/NaN in a numeric argument it does
not explicitly document handling for — behavior ranges from “propagates
cleanly to a non-finite result field” to “undefined/reads garbage,” and
is a direct consequence of the “domains are not checked” rule above.
Where a function does have documented
NA/NaN handling, it is because that function’s
job requires it (e.g. sample_mode_cpp() treats
NA — and, for doubles, NaN as a category
distinct from NA — as a first-class value that can itself
be “the mode,” because computing a mode over data that may contain
missingness is exactly the use case that function exists for). Do not
assume a fast_*_cpp fitting backend will produce a clean
NA in its output merely because its input contained one;
check the specific function’s own documentation.