Changelog
Source:NEWS.md
mixeff 0.2.0
Bridge performance: compile once, keep the fitted model (mixeff-rs bridge-perf)
-
lmm()andglmm()now compile the formula and audit the design once per fit. The engine builds the model from the spec compiled for the pre-fit explanation (CompiledModelSpec), so the second compile/audit pass and the second translation of the data to the engine are gone. The pre-fit explanation now shows the artifact the model is actually built from: for a formula with in-formula transforms the engine evaluates (such aslog(x)), or a||term on a factor, it reports the evaluated and expanded design.compile_model()is unchanged. - Fits now keep the fitted engine model as a native handle (
fit$rust_handle, seefit_handle_alive()). Computations that need the engine model reuse it instead of rebuilding and refitting the model from the stored data:summary()andanova()tests (Satterthwaite and Kenward-Roger),contrast(),test_effect(),compare(),test_random_effect(),confint(method = "profile"), bootstrap contrasts and intervals,parametric_bootstrap(),predict()on new data and with intervals orse.fit(LMMs and GLMMs),ranef(condVar = TRUE), GLMM bootstrap intervals, andverify_convergence(). Results are identical to the cold refit. Profiles and convergence checks work on a copy, so the cached model never changes. These calls stay interruptible. On a 200,000-row crossed LMM,contrast()drops from 0.39 s to 0.002 s,predict(newdata, interval = "prediction")from 0.70 s to 0.04 s andsummary()by the 0.45 s refit. On a 3,000-row binomial GLMM,predict(newdata, se.fit = TRUE)drops from 0.44 s to 0.006 s. - The handle is a process-local cache. It does not survive
saveRDS()/readRDS()or a new R process. Those fits, and fits where the handle does not match the requested refit (for example a profile under ML of a REML fit), fall back to the cold refit automatically, with identical results.revive()keeps a live handle and clears a dead one. Copies of a fit share the handle. - Memory: the handle keeps the engine model, including a copy of the model data, alive for as long as the fit object, outside R’s heap. To fit without keeping handles, set
options(mixeff.keep_handle = FALSE).update()andrefit()create new fits with their own handles.
Profile memory (mixeff-rs #12)
-
confint(method = "profile")andprofile()no longer build a dense n x n covariance for the fixed-effect profiles. A crossed LMM with 60,000 rows previously aborted R trying to allocate 28.8 GB; it now profiles in seconds with memory linear in the data. Leverage, Cook’s distance and new-data prediction variance also stop densifying the factor’s off-diagonal blocks once per row.
Engine follow-ups (mixeff-rs engine-followups)
GLMMs with a free dispersion parameter (Gamma, inverse Gaussian, and Gaussian with a non-identity link) now follow lme4 2.1-0’s estimated dispersion handling, which the engine adopts. The working weights carry
1/phi, andphiis profiled during the fit.sigma()issqrt(phi)withphi = deviance / (n - rank([X, Z])), and theta is the absolute random-effect SD.logLik(),deviance(),AIC(),BIC(),sigma(), theta andVarCorr()equalglmer()from lme4 >= 2.1-0. Fits of these families no longer match older lme4, and their estimates change from earlier mixeff versions. mixeff no longer shifts the logLik of Gamma and inverse-Gaussian fits by 1; the engine’s value already matches lme4 2.1-0.For these families
weights(fit, "working")is divided byphi, as in lme4 2.1-0. This also changesgetME()’sL,RZX,RXanddevcomp. GLMMdevcompdims gain lme4 2.1-0’sdispProfile,maxPhiIterandqEff.VarCorr()of every GLMM hasuseSc = FALSE, with no Residual row and absolute SDs, and keepssc = sigma().rePCA()no longer rescales GLMM covariances bysigma(). Hat values and Cook’s distances use the new weights; the values are unchanged.Known lme4 2.1-0 difference: for these families lme4 2.1-0 multiplies by
sigma()^2a second time in three places: the RX-basedvcov()(nAGQ = 0),predict(se.fit = TRUE)andranef(condVar = TRUE). mixeff reports the engine’s unscaled covariance there.The Nakagawa distribution-specific variance of a Gamma log-link GLMM is now insight 1.5’s log-normal approximation
log1p(sigma^2), notsigma^2.family()now works for Gaussian non-identity-link GLMMs.mm_r2(),mm_icc()andmm_variance_components()now support Gaussian GLMMs with a non-identity link. As insight 1.5 / performance 0.18 do forglmer()fits, the distribution-specific variance issigma()^2, and observation-level terms add no dispersion. This residual variance is on the response scale, so the R2 and ICC depend on the units of the response. Inverse-Gaussian GLMMs are still refused, now with reason code"r2_distribution_variance_undefined". insight 1.5 has no inverse-Gaussian case and falls back tosigma()itself, which is not a variance and changes when the response is rescaled.simulate()(and sorefit()-based bootstraps) now works for inverse Gaussian and Gaussian non-identity-link GLMMs. Previously these were refused with"simulate_family_unavailable". Draws follow the fitted variance function withphi = sigma()^2, and new random effects are drawn on lme4 2.1-0’s absolute scale. Gamma draws and unweighted Gaussian draws match lme4 2.1-0’ssimulate()draw for draw. The tests compare seeded draws with liveglmer()or with stored lme4 2.1-0 draws. Gaussian draws use prior weights (sd = sigma / sqrt(w)), which lme4 ignores. Inverse-Gaussian draws usestatmod::rinvgauss()(statmod is now in Suggests) with shapew / phi. lme4 2.1-0 instead usesw / sigma(), so its draws have variancesigma * mu^3rather than the fittedphi * mu^3.lme4 2.1-0’s
glmerControl()dispersion settings are now available asmm_control(disp_method = c("moment", "old/buggy"), disp_dof_correction, max_phi_iter). lme4’smaxPhiIterbecomesmax_phi_iter. They are sent to the engine (set_dispersion_method(),set_dispersion_dof_correction(),set_max_phi_iter()). Invalid values raisemm_arg_error, and unset ones keep lme4 2.1-0’s defaults ("moment",TRUE,100). They affect Gamma, inverse-Gaussian and Gaussian non-identity-link GLMMs. For other families and forlmm()they have no effect, and the fit says so with anmm_control_ignored_notice(lme4 ignores them silently). Because the control is stored on the fit,refit(),update(),simulate()-based refits, bootstraps and the R2 null model reuse it.getME(fit, "devcomp")dimsdispProfile,maxPhiIterandqEffreflect the settings. Under"old/buggy", the working weights do not carry1/phi, andVarCorr()/simulate()use theta itself as the random-effect SD, as lme4 2.1-0 does. Fits matchglmer(control = glmerControl(...))from lme4 2.1-0. Under"old/buggy"they agree to about 2e-4 in the estimates and 3e-6 relative inlogLik(), at the same lme4 deviance. The tests compare with stored lme4 2.1-0 values when the installed lme4 is older.confint(method = "profile")andprofile()no longer fail when one parameter’s profile is irregular. The row is kept with astatus("non_monotone","not_bracketing"or"failed"), areason_code("non_monotone_profile","profile_not_bracketing","profile_failed") and the engine’sreasoninattr(ci, "mm_profile")$table. Bounds the profile cannot determine areNA, and printing the intervals adds a note. Failed.sigNNrows now appear; previously they were dropped. lme4 instead warns and falls back to linear interpolation, which often gives[-1, 1]for a correlation. The profile CI JSON schema accepts the new fields, null bounds and the engine’s string notes.The multi-df Kenward-Roger F is now pbkrtest’s scaled
Fteststatistic (F = lambda * F_Uwith KR denominator df). Previously it was the unscaledFtestU. This affectscompare(method = "kenward_roger")andanova(ddf = "Kenward-Roger"), which matches lmerTest: the F value is scaled andMean Sqcomes from the unscaled F.$fixed_fgainsstatistic_scale,unscaled_statisticandunscaled_p_value;f_scalingis lambda.When a joint-Laplace GLMM’s Hessian is not positive definite, mixeff now reports Wald inference instead of withholding it, as glmer does. The fixed-effect covariance falls back to RX conditional on theta, as in glmer’s
vcov(use.hessian = FALSE), with reliability"low".summary()explains the fallback and prints the engine’s notes,vcov()warns (classmm_vcov_rx_fallback), andinference_options()labels the routeglmm_laplace_rx_conditional_on_theta_wald. These standard errors ignore uncertainty in theta.A joint-Laplace fit that the engine labels
not_optimizedis now shown as “convergence not certified” when the optimizer stopped normally within its budget and the estimated objective gap is at most1e-2deviance units. The engine certifies only at1e-6. This is a warning-level label, like lme4’s convergence warnings, and does not refuse the fit.fit_status()still returns the engine’s label.inference_options()on joint-Laplace GLMMs no longer describes withheld Wald rows as “uncertified for the profiled estimator”. It also reports the parametric bootstrap as refused, matchingconfint().
Correctness fixes (pre-release audit)
- Character fixed-effect predictors (e.g. columns from
read.csv()) are now converted to factors with sorted levels, as lme4 does. Previously the fit aborted with anmm_schema_errorwhile building coefficient names. - A numeric predictor whose name starts with a factor’s name (
groupandgroup_size) no longer aborts the fit. - Contrast codings mixeff cannot honour are refused with an
mm_arg_errorinstead of being silently replaced by treatment coding: a contrast attached to an unordered factor, a non-contr.treatmentunordered entry inoptions(contrasts =), andcontrasts = list(f = "contr.SAS")(which uses the last level as reference; the engine always uses the first). -
update()re-evaluates the originaldatawhen the updated formula needs columns the stored model frame lacks (as lme4 does), and refuses arguments it cannot carry over (e.g.subset,contrasts) instead of dropping them. It carries the fit’sna.actionover (and accepts a new one). - The declared Rust toolchain minimum is now 1.85, the bundled engine’s actual requirement (some vendored crates use edition 2024).
- The lme4 migration vignette no longer describes
coef(),VarCorr(),residuals(),anova(),getME(),isSingular()andemmeansas identical to lme4; it lists the actual differences.
lme4 parity and checklist completion
Missing values are handled as in
lmer()/glmer():lmm()andglmm()honourna.action, whose default is nowgetOption("na.action")(na.omitunless changed) instead of refusingNA. Only the variables the model uses count (fixed and random terms, evaluated transforms,weights,offset), so anNAin an unused column never drops a row. Dropped rows are announced with a typedmm_rows_droppedmessage giving the number of rows and the variables with missing values (silence withmm_control(verbose = -1)), recorded asna.action(fit)andattr(model.frame(fit), "na.action"), and excluded fromnobs().na.excludepadsresiduals()/fitted()/predict()for both fitters;na.failandna.passare refused with a typedmm_data_error.simulate(),refit(),update(), bootstrap and influence measures use the fitted rows, andcompare()/anova()refuse models fitted to different numbers of rows (reason_code = "different_nobs"), as lme4’sanova()does.-
Shapes follow lme4 >= 2.0 and insight >= 1.5:
-
confint(method = "profile")numbers.sigNNterm by term, each term’s standard deviations before its correlations. lme4 1.1 numbered them in lower-triangle order. -
anova(m1, m2)names its-2 * logLikcolumn-2*log(L), where it wasdeviance. -
getME(m, "devcomp")$dimsgainsnpar. -
print(VarCorr(m))usesreformulas::formatVC()when reformulas is installed. - For GLMMs,
mm_r2()/mm_icc()divide the binomial distribution-specific variance of acbind()response by the mean number of trials. For count models, the null-model mean isexp(b0 + v0/2), whereb0is the null model’s intercept andv0its random-effect variance.
-
residuals()follows lme4: LMMtype = "pearson"/"deviance"aresqrt(w) * (y - mu)(no longer divided by sigma), andscaled = TRUEdivides bysigma()exactly once (it divided twice for Pearson). GLMM residuals default to"deviance"and support"pearson","working"and"response", computed from the family, prior weights and binomial trials.sigma()of a negative-binomial GLMM is 1, as in lme4; theta isgetME(fit, "glmer.nb.theta"). GLMMdeviance()is the sum of squared deviance residuals (as lme4), no longer-2 * logLik().coef()returns every fixed-effect column per group in lme4’s order.VarCorr()returns lme4’s structure (named list of covariance matrices withstddev/correlationattributes,sc/useSc), soVarCorr(m)$Subjectis the matrix; it prints like lme4.VarCorr(m)$tableand$residual_sdstill work.AIC(m1, m2)/BIC(m1, m2)return stats’df/AICdata frame instead of refusing.New
REMLcrit(),family()methods,weights(type = "working");weights()returns ones for unweighted fits;model.frame()has lme4’s transformed columns andtermsattribute (raw variables stay infit$model_frame).getME()covers lme4’s components (u,b,L,RX,RZX,devcomp,lower,Gp,Tp,Lind,Ztlist,Tlist,ST,offset,weights,glmer.nb.theta,"ALL", …) for LMMs and GLMMs;devfunis refused with a typed error.Lambda/Lambdat(andmodel.matrix(fit, type = "random")) now follow the engine’s (lme4’s) term order; previously theta was assigned in formula order, so crossed designs such as Penicillin’s(1|sample) + (1|plate)gave a term another term’s theta.is_singular()uses lme4’s rule (a zero-bounded theta belowtol), honourstol, and works for GLMMs.ranef(condVar = TRUE)returns Laplace conditional variances for GLMMs instead ofNA.summary()andanova()accept lmerTest’sddf = "Satterthwaite","Kenward-Roger"or"lme4".summary()$coefficientsis lmerTest’s numeric matrix (per-row method labels moved tosummary()$coef_table).anova(m)is lmerTest’s data frame (Sum Sq Mean Sq NumDF DenDF F value Pr(>F)) andanova(m1, m2)lme4’s (npar AIC BIC logLik deviance Chisq Df Pr(>Chisq)); mixeff’s provenance table stays in$table.confint()accepts lme4’smethod = "Wald"and"boot"spellings.Stateful and expanded fixed-effect formula terms now work in
lmm()andglmm():factor(),relevel(),cut(),scale(),poly(),splines::ns()/bs(),as.numeric(),I(x > 0),offset(),^,%in%, and parenthesised groups such as(a + b)^2anda * (b + c). Coefficient names,terms(),model.matrix(),drop1()andemmeansgrids follow lme4, andpredict(newdata)reuses the training basis.lmm()gainsoffset =(andoffset()formula terms); fitted values and predictions include the offset, as in lmer.glmm()now acceptssubset,na.actionandcontrastswith the same data preparation aslmm(). Itsna.actiondefault is nowNULL(refuseNA, likelmm()); the oldna.omitdefault was never applied.na.action = na.excludepadsfitted(),residuals()and in-samplepredict()to the original rows; the na.action record is kept on the fit (fit$na.action, model-frame attribute) and used by the emmeans bridge.Unused factor levels (fixed and grouping) are dropped after
subset/NA removal, songrps()and coefficient sets match lme4.predict(newdata)keeps only the variables it needs (an unused Date column no longer fails), coerces integer/character grouping and character/logical fixed columns like the training frame, returnsNAfor incomplete rows (newna.actionargument, defaultna.passas in lme4), accepts partialre.formformulas such as~ (1 | g), and evaluates formula offsets fromnewdata(anoffset =argument offset must be resupplied viapredict(offset =)).Interaction and nested grouping factors are labelled as in lme4 (
a:b, andb:afor the nested term ofa/b, with levelsx:y) inranef(),VarCorr()andngrps(); GLMMpredict(newdata)works for them.Logical predictors are coded as factors with levels
FALSE/TRUE, asmodel.matrix()does, so no-intercept and margin-free interaction models match lme4 (lFALSE,lTRUE).Ordered grouping factors are grouped as unordered factors (no dense
contr.polybasis is built for them).simulate()now works for GLMMs (binomial, Poisson, Gamma, negative binomial) andrefit()re-fits a GLMM to a new response. Binomialcbind()fits simulate two-column count matrices and proportion fits simulate proportions, as in lme4.Breaking:
simulate()now uses lme4’sre.formmeaning. The defaultre.form = NA(or~0, oruse.u = FALSE) draws new random effects;re.form = NULL(oruse.u = TRUE) conditions on the fitted random effects. PreviouslyNULLdrew new random effects andNAleft them out entirely. Seeded draws now reproduce lme4’s when the estimates agree.New diagnostics:
plot()on a fit draws Pearson residuals against fitted values (also Q-Q and scale-location viawhich =),qqnorm()works on fits, andplot()/qqnorm()onranef()output draw caterpillar and Q-Q plots with conditional-variance intervals. With lattice loaded,dotplot()andqqmath()work onranef()output as in lme4.New
hatvalues(),cooks.distance()andinfluence()(case or group deletion refits, withdfbeta(),dfbetas()andcooks.distance()methods for the result). LMM values match lme4. GLMM hat values use the working weights, asglmdoes, so they differ from lme4’s.New
mm_r2(),mm_icc()andmm_variance_components()compute Nakagawa marginal/conditional R2 and adjusted/unadjusted ICC. With performance and insight installed,performance::r2(),performance::icc()andinsight::get_variance()also work on mixeff fits and agree with their values on the matching lme4 fit.Breaking:
glmm()now defaults tomethod = "joint_laplace"(glmer’snAGQ = 1estimator, certified Wald inference).method = "pirls_profiled"remains available explicitly, andnAGQ = 0selects it (lme4’s fast estimate). Without an explicitmethod, negative-binomial families,nAGQ > 1, andinference = "working_hessian"use the profiled path with anmm_estimator_notice; explicitmethod = "joint_laplace"requests for them are refused.LMM bootstraps (
bootstrap_control(seed = NULL), the default) now draw their engine seed from R’s RNG, soset.seed()makes them reproducible.simulate.mm_lmm()’s"seed"attribute followsstats::simulate(the prior.Random.seed, or the seed with its RNGkind).drop1(), random-term LRTs, and random-structure candidates keep a no-intercept model no-intercept instead of re-adding the intercept.refit()on a transformed response (log(y) ~ ...) refits to the new response (previously it returned the original fit); it keeps the fit’smm_control().Internal refits no longer resolve
weightsthrough the data mask (a data column namedfit,full, orobjectbroke them), and REML-to-ML refits incompare()/anova()keep the user’smm_control().fixef(),ranef(),VarCorr(),ngrps(),getME()andrefit()forward nlme (lme,lmList,gls) and lme4 objects to the owning package’s generic, so attaching mixeff no longer breaks nlme fits.summary()/inference_table(method = )compute all coefficient rows in one engine call instead of one refit per coefficient. Engine refits keep the fit’s fullmm_control()(a userstartis no longer rounded to 4 digits on the wire).glmm()fits every family/link pair the engine supports:Gamma()with its default inverse link,inverse.gaussian()with"inverse"/"log", andgaussian()with"log"/"inverse"/"sqrt"(glmer parity tests). Binomial cauchit/log/identity, Poisson identity, and inverse.gaussian’s default1/mu^2link are refused with a typed condition naming the supported set.Fits, refits, bootstraps and profiles can be interrupted (Ctrl-C/Esc): the engine checks for a pending interrupt between optimizer evaluations without longjmp-ing through Rust frames, and stops with a typed
mm_interruptederror.New
mm_bootmer()(lme4::bootMer()counterpart; aboot-compatible result forboot::boot.ci()),rePCA(),mm_lmlist()(lmList()),mm_allfit()(allFit()), andmm_control(optCtrl = )(lme4-style optimizer controls, unknown names refused).compare(small, big, method = "kenward_roger")(and"satterthwaite") gives pbkrtest’sKRmodcomp()/SATmodcomp()F test for nested fixed effects;step()gives lmerTest-style backward elimination formm_lmmfits (mixeff now exports astep()generic whose default isstats::step()).Fast examples no longer use
\dontrun{}; the fit result’s per-observation vectors cross the bridge as R doubles instead of JSON and are stored once in the fit object.Single-model
anova()tests a multi-column term such aspoly(x, 2)as one multi-df row (joint F with the same df method), matching lmerTest, when no other term contains it.Engine error messages containing
%(e.g. a formula with%in%, or a column name with%s) reach R verbatim: extendr passed the message toRf_error()as a printf format string, which garbled them and could read out of bounds.Engine re-pinned to mixeff-rs 2873312.
||with a factor now expands like lme4 ((1 + x + f || g)is(1 | g) + (0 + x | g) + (0 + f | g)), so parameter counts,df, AIC and estimates match lme4 (they used to differ when a factor sat inside||); random-effect factor bases usemodel.matrix()coding.diag(1 + f | g)gives MixedModels.jlzerocorr()semantics.VarCorr(),as.data.frame(VarCorr()),ranef(),ngrps()andgetME()(theta,Lambdat,Lind,Zt,cnms, …) present random-effect terms in lme4’smkReTrmsorder, with||pieces as separate entriesg,g.1, …;getME(fit, "theta")is permuted to lme4’s order.getME()handles factor random-effect bases.as.data.frame(VarCorr())lists covariance rows in lme4’s order.confint(fit, method = "profile")/profile()return lme4’s rows (.sig01, …,.sigma, fixed effects) on the SD/correlation scale and profile REML fits on the ML deviance, as lme4 does, so fixed-effect profile intervals are now available for REML fits (theprofile_beta_unavailable_under_remlrefusal is gone). Theta-scale rows stay inattr(ci, "mm_profile")$table. The LMM default remains Wald.New
threadsargument for bootstraps and profiles (bootstrap_control(threads = ),parametric_bootstrap(),compare(method = "bootstrap"), GLMMconfint(method = "bootstrap", threads = ), LMMconfint()/profile()): results are identical for every thread count; default 1 (CRAN’s two-thread policy); workers never call into R and Ctrl-C still interrupts.Rank-deficient fixed effects keep the earlier of two collinear columns (R’s rule).
fixef()omits dropped coefficients (add.dropped = TRUEgivesNA), andvcov()/summary()cover the estimable coefficients, matching lme4. Fixed: a dropped coefficient’s missing standard error shifted every later standard error by one position.Gamma and inverse-Gaussian GLMMs:
logLik(),AIC(),BIC()and anova tables report glmer’s values (the engine’s density is 1 higher because glmer includes the familyaic()’s+2); estimates,sigma()and SEs match glmer. Inverse-Gaussian fits now havesigma(),family()and a residualVarCorr()row. Negative-binomial theta now maximizes the GLMM likelihood likeglmer.nb().test_random_effect()labels a correlation-only comparison (e.g.||versus|) as an ordinary chi-square test; the 50:50 boundary mixture is used only when exactly one variance is added.The aphantasia reproduction (
MIXEFF_RUN_APHANTASIA=true) now reaches strict lme4 parity on every case with lme4’s||expansion: primary, intact and combined run the default joint-Laplace estimator, and the parity-ledger exemptions for the primary DiD estimate/SE, intact AIC and combined fixed effects are retired. Theinference = "working_hessian"documentation no longer says its SEs run ~11% below glmer’s (that gap came from the old||family; they now agree within 1% on this dataset).
Compatibility
- Fit-summary parsing accepts the additive
mixedmodels.fit_summary1.1.0 schema emitted by mixeff-rs 1.0.0-rc.4, while retaining 1.0.0 support. Covariance provenance is preserved in the stored payload. Unknown schema versions and malformed payloads remain errors. - The bundled
mixeff-rsengine moves from rc.1 (1f3f689) to rc.5 plus pre-release fixes (accd4b1). This brings the upstream fixes for joint Laplace GLMM convergence without NLopt (and Ctrl-C during its inner PIRLS), GLMM parametric-bootstrap replicates that silently used the fast estimator, colliding(1|a:b)interaction keys, Wald p-values underflowing to 0, and negative-binomial deviance precision; it is also 2-7x faster on vector-valued and crossed models. New in this pin:-
y ~ 0 + fcodes the first factor with one column per level, as R’smodel.matrix()does; previously the reference level was silently dropped (its mean forced to zero). -
predict(newdata =)uses the fit’s design coding, fixing wrong predictions for non-marginal formulas such asy ~ f / h. - LMM prior weights are validated (length, finite, positive) and simulation / parametric bootstrap draws residuals with sd
sigma / sqrt(w); binomial GLMM responses above 1 are refused instead of producing NaN estimates.
-
-
mixedmodels.fixed_effect_inference_table1.2.0 (per-row covariance provenance) is the current schema; stored 1.1.0 tables still parse. -
simulate()for LMMs scales residual noise by prior weights, and keeps a zero-variance random-effect term at zero instead of borrowing another term’s variance.
Breaking: API-shape stabilization
-
audit()is now the single audit verb, dispatching on both compiled specs and fits;audit_design()forwards with a deprecation warning and will be removed later. -
df_for_contrast()andreporting_table()now returnmm_*objects with$table(plus$dfon the former,$sectionsforreporting_table(section = "all")), matching every sibling analysis verb, instead of a bare classed vector / data frame. - The spec-accepting inspection verbs (
changes(),parameterization(),reproducibility(),random_blocks(),optimizer_certificate(),reporting_table(),audit()) name their first argumentobject(previouslyfit, which was misleading for specs). Fit-only inference verbs keepfit.model.frame.mm_lmm()keepsformula— that name is imposed by thestats::model.frame()generic. -
confint()presents"asymptotic"as the canonical method name (the package-wide term for the closed-form Wald interval);"wald"remains an accepted synonym. Computation is unchanged. -
drop1()now matchesstats::drop1()marginality semantics: by default, main effects participating in an interaction are not offered for dropping. An explicit non-marginalscopeis still honoured and fits normally. The result gainsstatusandreasoncolumns.
Breaking: lme4-identical coefficient names
- Fixed-effect coefficient names now match
lme4/model.matrix()exactly —"recipeB","temperature.L","recipeB:temperature.L"— inmodel.matrix()column order, on every programmatic surface:fixef(),coef(),summary()tables,vcov()dimnames,confint(),contrast()andmm_lincomb()weight names,tidy(),emmeans, andpredict(). Previously mixeff used its engine encoding ("recipe: B") with a different interaction column order, so linear combinations and coefficient lookups copy-pasted fromlme4code silently misaligned. Code written against the old names must switch to the lme4 forms. The engine encoding still appears inside engine-renderedexplain()/audit()prose.ranef()column names are stripped to the lme4 form ("modalityAudio");VarCorr()printing is unchanged. Fits saved withsaveRDS()by older versions lack the stored name map and should be re-fit.
Engine
- Engine pin bumped to
4a2abb3: hardens convergence and runtime contracts. Non-marginal designs (y ~ b + a:b) now fit and matchlme4exactly (previously refused). Convergence labelling is more conservative — a fit that reaches a flat/boundary region without a certified stationary point is now reportednot_optimized(previously sometimesconverged_reduced_rank) on small maximal random-slope models; genuine reduced-rank optima (e.g.lme4::Dyestuff2) still reportconverged_reduced_rank. The prior pin,ee0c717, carried the audit-render wording batch (policy recommendations phrased as options, boundary-sentence deduplication, humanized summary-view jargon). - Earlier in this cycle the pin moved to
3b6ec69(one commit past v1.0.0-rc.1), which fixes the native crossed-LMM trust-region start. Crossed-design fits that route through the trust-region optimizer may land on very slightly different (better-started) optima. - The bundled
mixeff-rsengine is now pinned to its first tagged release, v1.0.0-rc.1 (3332f3e). The two response-batch diagnostic reasons new in this release (sink_stopped,adaptive_refinement) are registered in the R-side reason registry, so the coverage contract (every engine reason has an R-side entry) holds.
Extractors
-
VarCorr()correlations are now stored at full precision as numeric columns of$table(correlation, pluscorrelation2, … for groups with three or more random terms;NAwhere no pair exists). Previously the column was a 2-decimal display string, forcing callers to parse text and costing ~2% precision on well-determined correlations. Printing is unchanged: rounding to 2 decimals now happens only at display time.
Negative-binomial GLMMs
-
glmm()now fits NB2 negative-binomial models (log link) on the default profiled path.family = mm_negative_binomial()estimates the size parameter theta alongside the model (thelme4::glmer.nb()route);family = MASS::negative.binomial(theta)ormm_negative_binomial(theta)fits conditional on a fixed theta. The fitted/fixed theta is recorded asfit$family$nb_theta.method = "joint_laplace"is not yet wired for this family at the pinned engine and is refused with a typed error.
Clearer user-facing text (UX parity pass vs lme4)
A 13-scenario side-by-side battery against lme4 (graded independently) drove a cleanup of every surface where engine internals leaked into user-facing text:
- GLMM summaries with withheld inference now explain plainly that the fast default cannot certify SEs/z/p and to re-fit with
method = "joint_laplace"; the engine’s covariance-geometry warrant moved behindprint(summary(fit), verbose = TRUE). - Unsupported-family errors list the supported families and point to
lme4::glmer()for the rest. - The new-grouping-level prediction error describes the R-level remedies (
re.form = NA,allow.new.levels = TRUE) instead of Rust API names, and bridge errors no longer print a duplicated “Caused by” chain. -
anova()prints a compact lme4-shaped table; single-df terms display as the equivalent F statistic (matchinglmerTest), and provenance/list columns stay in$table. - Aliased (rank-deficient) coefficients display as
NAwith an explicit note, instead of a misleading0. -
print()no longer emits the artifact/crate provenance line (available onfit$schema);confint()’s internal certification label is translated at display.
Fixes and runtime notices
-
parameterization()on a fitted GLMM reported the compile-time Lambda template (1s and 0s) astheta_valueinstead of the fitted theta; it now splices the fitted values in by index (LMM fits were unaffected; pre-fit specs keep the honest template). - Logical random slopes now carry lme4-style
ranef()column names ("xTRUE"), consistent with the fixed-effect naming and the conditional variance arrays. -
glmm(method = "joint_laplace")emits an up-front runtime notice: the joint route optimizes to an engine-chosen budget inside a single silent native call and can take minutes on large data (cap withmm_control(max_feval = )). Summary notes for completed joint fits no longer imply an unusable fit when the engine’s convergence label isnot_assessed/not_optimized(label reliability is tracked upstream); they point toverify_convergence(). - Bootstrap-based inference with 200+ replicates announces its scale before the single silent native call.
-
lmm()/glmm()now emit a rescaling advisory when a continuous predictor is on a scale far from 1 (matchinglme4’s “predictors on very different scales” guidance): such fits can converge poorly, andscale()is the cheap fix. A notice, not a refusal; suppress withmm_control(verbose = -1). -
lmm()/glmm()document that optimization is silent and non-interruptible within one native call, with bounded budgets.
Profiling and scope
- New
profile.mm_lmm()method: returns anmm_profileobject over the engine’s certified profile-likelihood payload ($tablewith one row per parameter; REML fixed effects carry an explicitprofile_beta_unavailable_under_remlreason instead of being dropped).confint()on the profile reproducesconfint(fit, method = "profile"). -
lmm()now refuses multivariatecbind(y1, y2)responses with a plain error (fit each outcome separately); shared-theta multivariate models are deferred post-release.glmm()continues to acceptcbind(successes, failures)for binomial responses.
Prediction
- Population-level predictions (
re.form = NAor~0) no longer require the random-effect grouping columns innewdata, matchingpredict(lmer/glmer, re.form = NA). Only the fixed-part variables are needed; conditional predictions (re.form = NULL) still require the full formula’s variables.
Contrasts
- Ordered factors are now coded with orthonormal polynomial contrasts (
contr.poly) at fit time, matching R/lme4 defaults, instead of treatment coding. Fixed effects, random-slope (Z) coding,logLik/AIC/BIC, and predictions now reach parity withlme4on ordered-factor models (e.g.lme4::cake). Coefficient names still use mixeff’s engine encoding (temperature: .L) pending the lme4-identical renaming layer. If the global ordered-contrast option is notcontr.poly, or an ordered column carries an explicit non-polycontrastsattribute,lmm()/glmm()refuse with a typedmm_arg_errorrather than silently diverge from the requested coding. - Behavior change: a one-level ordered factor now errors loudly (polynomial contrasts require at least two levels) instead of silently degenerating to an empty/near-empty design.
Diagnostic clarity
- New verb
verify_convergence(): re-runs a fitted LMM under the engine’s bounded verification workflow (restart from the optimum, jittered restarts, opt-in alternate-optimizer consensus) and reports the engine’s verdict with per-run objective/theta/beta deltas. This is the check the audit surface already pointed to for uncertain optima; the verdict and wording are engine-owned.consensusdefaults toFALSEbecause this vendored build compiles without the optionalnloptbackend, whose absence the consensus pass would otherwise report as a spuriousfragile. -
changes()now prints one plain-language sentence per recorded change (e.g.Fitted covariance for (1 | s): requested rank 1, fitted rank 0 [reduced_rank].) instead of dumping the raw stage table. The certificate-time rank statement is treated as the canonical record of a boundary event, so its design/covariance restatements are not repeated (they remain in$table). A fit whose optimizer stopped early now says so explicitly (none: no structural change was made; the optimizer stopped early (fit status \not_optimized`).) instead of showing a misleadingunchanged / formula display` row. - The automatic pre-fit
explain_model()block emitted bylmm()/glmm()now travels on the message stream (a typedmm_explanation_noticecondition) instead of stdout, sosuppressMessages()and knitr’smessage = FALSEcan quiet it. It remains on by default;mm_control(verbose = -1)still suppresses it entirely, and an explicitprint(explain_model(spec))still writes to stdout. -
summary()on a GLMM now defaults totests = "coefficients"(matchinglme4::glmer). When the fit method cannot certify fixed-effect inference (the defaultpirls_profiledestimator), the SE/z/p columns are still withheld — but aNotes:line now states why, and that engine-certified Wald inference is available from amethod = "joint_laplace"fit. Previously a defaultsummary()printedNAcolumns with no explanation. -
summary()on a fit whose optimizer stopped without certifying an optimum (e.g. fit statusnot_optimized) now repeats that state as a plain-languageNotes:line directly under the coefficient tests, instead of relying on the header status line alone. - Displayed p-values now render through
format.pval(): an underflowed p-value prints as< 1e-16instead of0.000000e+00. Stored values are unchanged. - The singular-fit
print()footer only advertisesrandom_options(spec, group = ...)when that call can actually run for the fit (a slope candidate exists); previously the printed hint could error on the very fit that printed it.
lme4 functional-equivalence layer
- Grouping-variable coercion:
lmm()/glmm()now coerce a non-categorical grouping variable (e.g. an integer subject id or numeric item code) to a factor for the random-effects structure, matchinglme4/nlme/glmmTMB. Previously such a column was rejected by the native fit with “grouping factor not categorical”. The coercion is announced via a suppressible notice (classmm_grouping_coercion_notice; silence withmm_control(verbose = -1)), never silent. Surfaced by an in-the-wild OSFglmerreproduction with crossed(1 | ID) + (1 | Title)effects. - Certified GLMM Wald inference: when fit with
method = "joint_laplace",summary(),confint(method = "wald"),contrast(), andtidy()now report engine-certified fixed-effect standard errors, Wald z statistics, and p-values that matchlme4::glmer()within tolerance. The defaultmethod = "pirls_profiled"path is not certified for fixed-effect inference, so all four surfaces withhold SE/z/p (returningNAwith a reason and avcov_statusof"unsupported") rather than fabricate them from the uncertified working Hessian — consistent with the package’s “no fake certainty” contract. - Conditional prediction standard errors and intervals:
predict()formm_lmm/mm_glmmwithre.form = NULLnow routesse.fitandintervalthrough the engine’s prediction-variance payload, which includes the random-effect (BLUP) variance and the fixed/random covariance — a surfacelme4::predict.merModdoes not offer at all. LMMs get conditionalse.fitplus"confidence"and"prediction"intervals; GLMMs get conditionalse.fitand"confidence"intervals on the link or response scale (variance propagated through the link by the engine). The engine certifies these rows formethod = "joint_laplace"fits and — via a post-fit profiled-optimum certificate — for defaultpirls_profiledfits, so the default estimator now reports conditional SEs too. Rows the engine does not certify are withheld, not fabricated: uncertified fits (e.g. singular fits, whose certificate is never issued) and unseen grouping levels underallow.new.levels = TRUEreturnNAwith the engine’s reason in themm_reasonattribute. Population (re.form = NA) SEs/intervals are unchanged. - GLMM prediction (future-observation) intervals:
predict(interval = "prediction")now works for conditional, response-scale GLMM predictions. Bounds are quantiles of the plug-in predictive distribution (the family conditional distribution mixed over link-scale fitted-mean uncertainty via Gauss–Hermite quadrature), so they are integers for count families and support points for Bernoulli; the interval is at least as wide as the corresponding confidence interval. Typed refusals remain for link-scale requests (future observations are response-scale objects), population-level requests, and grouped binomial fits (the future trial count is not representable innewdata). -
||factor-term semantics documented and contract-tested: in mixeff, zero-correlation syntax fully decorrelates the block — a factor’s treatment-coded level contrasts get independent variances with no within-factor covariances (the principled reading, shared byafex::mixed(expand_re = TRUE),glmmTMB::diag(), andMixedModels.jl zerocorr()).lme4’s||instead leaves factor terms intact with a full within-factor covariance block, so the same formula fits a larger (and over-parameterized) model there. Fits announce the situation with an info diagnostic (covariance_assumption, reasondouble_bar_factor_term) naming the correlated-block rewrite ((0 + f | g)); the lme4-migration and formula vignettes carry the recipe. - Binomial response coercion:
glmm()withfamily = binomial()now accepts a logical response (coerced 0/1 silently) or a two-level factor response (coerced with the second level as success, announced via a suppressiblemm_factor_coercionmessage), matchingstats::glm()/lme4::glmer(). A factor with any other number of levels aborts with a typedmm_data_error. Previously these responses surfaced as an opaque engine error. -
update()formm_lmm/mm_glmm: formula edits (. ~ . - x, preserving random-effect bars and||),REML/weights/family/offset/method/controloverrides, newdata, andevaluate = FALSE. -
broom/broom.mixedsupport:tidy(),glance(), andaugment()methods formm_lmm/mm_glmm(registered withgenerics). -
confint.mm_glmm(): asymptotic Wald intervals for GLMM fixed effects (refuses profile/bootstrap with a typed reason). -
predict.mm_glmm():type = "link"/"response", population and conditional (re.form = NULL/NA) predictions withallow.new.levels, replacing the previous refusal. Validated against the engine’sfitted(). - GLMM fixed-effect inference:
contrast.mm_glmm()(Wald),drop1.mm_glmm()(refit LRT), andanova.mm_glmm()(sequential LRT for nested models). - GLMM estimator transparency: the native
method = "joint_laplace"path is certified againstlme4::glmerwithin tolerance, andglmm()now emits an informational notice (classmm_estimator_notice) when the defaultpirls_profiledestimator is used, since its coefficients are not glmer-equivalent (usemethod = "joint_laplace"for parity).
mixeff 0.1.0
First public release of mixeff, an audit-first R wrapper around the mixedmodels Rust crate. The package is distributed via R-Universe at bbuchsbaum.r-universe.dev; the upstream nlopt feature-gate PR that lands CRAN distribution is tracked separately and ships as 0.2.0.
Phase 0 — bridge and contract foundation
-
rextendr/extendr_apibridge with vendored upstreammixedmodelscrate; CRAN-compatible build withcargo vendor+vendor.tar.xzreconstitution atR CMD INSTALLtime. -
mm_parse_formula()— R/Rust formula round-trip primitive. -
mm_formula_manifest()— capability discovery for the bridge. -
mm_json_negotiate()andmm_json_known_schemas()— schema versioning gate; mismatched artifacts raisemm_schema_errorrather than silently misparse. - Interrupt FFI:
Ctrl-Cduring a long Rust fit cleanly returns to R. [Correction, 0.2.0: this applied only to the interrupt bridge demo (mm_interrupt_demo), never to real fits — ordinarylmm()/glmm()optimization runs in a single non-interruptible native call. See the 0.2.0 documentation.] - Typed condition catalog (
mm_conditionbase class):mm_formula_error,mm_data_error,mm_schema_error,mm_design_refusal,mm_inference_unavailable,mm_fit_error,mm_not_identifiable,mm_fit_not_optimized.
Phase 1 — audit-first construction surface
-
compile_model()— formula + data → semantic IR + design audit, no fitting. -
audit_design()— structured design audit; raisesmm_design_refusalfor non-identifiable terms before any optimization runs. -
explain_model()— auto-printed once bylmm()/glmm()before the fit. Translates each random term into named-argument form, prints the per-block English gloss (authored in Rust), and emits the mandatoryNo random slopes were added.sentinel for intercept-only random terms. -
random_options()— opt-in map of nearby random-effect spellings for a grouping factor (punt, slope-only, split-uncorrelated, double-bar, full). No “recommended” column; no preference ordering. -
compare_covariance()— full / diagonal / scalar comparison per random term. -
changes(),diagnostics(),fit_status(),parameterization(),roles(),as_json(),is_singular(). -
lmm()— REML/ML linear mixed-model fit via the upstream RustLinearMixedModelengine. Returns a serializablemm_lmmcarrying the JSON artifact, parsed state, beta/theta/sigma/logLik/deviance, fitted values, residuals, random effects, and varcorr. - lme4-style extractor surface for
mm_lmm:fixef,ranef,coef,VarCorr,sigma,logLik,deviance,AIC,BIC,nobs,formula,model.frame,df.residual,fitted,residuals(type="response"), basicpredict(). - Pedagogical
DiagnosticCodevariants surfaced from Rust:ScopeNote,SupportNote,SyntaxExpansion,CovarianceAssumption,StructuralRefusal. - Random-term card schema (
RandomTermCard) shipped per random term withterm_id,original_fragment,canonical_fragment, group, blocks,implied_constraints, anddesign_support. - R9 “no advice creep” contract enforced by
test-no-advice.R: the strings"suggested starting model","we recommend","you should","try ... instead","drop the random slope", and"same model, different font"cannot appear in package output. - Vignettes:
intro.Rmd,lmm-basics.Rmd,demystifying-formulas.Rmd.
Phase 2 — saveRDS round-trip and lazy extractors
-
saveRDS/readRDSsurvives without a live Rust handle — the artifact is the source of truth. -
revive()— rebuilds the Rust handle from the durable artifact when a live cache is needed. -
fit_handle_alive(),getME(fit, name)forX,Z,theta,Lambda,cnms,flist,Gp,lower,devcomp,optinfo. -
model.matrix(type=),vcov(type="fixed"). -
random_blocks()— per-block decomposition of the random-effects matrix. -
optimizer_certificate()— convergence status, iterations, objective trace, verification trace. -
inference_table()— per-coefficient method/status/reliability rows read from the Rust inference contract. -
reproducibility()— Rust-authored reproducibility envelope (engine version, schema version, seed, optimizer fingerprint). -
is_singular()— boolean predicate over the optimizer certificate. - Vignette:
saving-and-reviving.Rmd.
Phase 3 — LMM inference
-
contrast(fit, L, rhs, method)— fixed-effect contrast front door. Methods:"auto","satterthwaite","kenward_roger","bootstrap","asymptotic","none". Returnsmethod/status/reliability/reasoncolumns; never fabricates p-values where the engine cannot certify a method. -
test_effect(fit, term, method)— term-level hypothesis tests. Bootstrap and bootstrap-LRT methods backed by the upstream Rust bootstrap entry points; cluster bootstrap is recognized but documented as estimator-distribution only (no certified p-value in schema 1.0.0). -
inference_table(fit, method)— multi-row inference table. -
df_for_contrast(),estimability()— placeholders that returnNAwith a stable reason until 0.2.0 wires the Rust certificates end-to-end. -
anova()— single and multi-model. -
drop1.mm_lmm(). -
confint(method = "wald")— Wald asymptotic interval flagged with status"not_certified_by_rust_inference_contract". -
confint(method = "bootstrap")— full-model bootstrap intervals with percentile / basic selection and bootstrap metadata. -
bootstrap_control()— control object for bootstrap-backed methods (replicate count, seed, failed-refit policy). - Vignettes:
inference.Rmd,inference-where-lme4-says-no.Rmd.
Phase 4 — GLMM boundary and LMM lifecycle
-
glmm()— Phase 4 boundary. The upstream Rust bridge does not yet expose a GLMM fit primitive, soglmm()validates the family/link request, compiles the model spec, and raises a typedmm_fit_errorwith the expectedfamily/link/nAGQmetadata until the bridge primitive lands. (Real GLMM fitting is queued for 0.2.0; upstream FFI is available.) -
simulate.mm_lmm()— simulate from a fitted LMM using the durable artifact state. -
refit()— refit with a new response. -
compare()— model comparison with auditable validity status. - Multi-model
anova()anddrop1()overmm_lmmobjects. -
parametric_bootstrap()— parametric bootstrap distribution for fixed-effect tests. - Manifest capabilities for
simulate/inferenceexposed via the bridge contract. - Vignettes:
glmm.Rmd(boundary walkthrough),benchmarking.Rmd,reporting-lmms.Rmd.
Cross-cutting infrastructure
-
mm_control()— flat named list mirroringlmerControl. Honored fields includeoptimizer,optimizer_max_iter,optimizer_xtol_abs,optimizer_ftol_abs,reml,nAGQ,verify_convergence,parallel_threads,seed,verbose,thresholds,schema_version,bridge_timeout_s. -
mm_thresholds()— design/identifiability thresholds (byte-equivalent to the upstreamcompiler_contract_v0_prd.md§8). - Parity ledger (
inst/extdata/expected-mismatches.json) — every divergence fromlme4is classified (expected_mismatch/upstream_bug/unsupported) with bounds enforced bytests/testthat/helper-parity-scoreboard.R. - Parity scoreboard (
test-parity-scoreboard.R) — emits a structured artifact recording observed differences against tolerances on the classiclme4parity baseline. - Speedup vs
lme4on the included scaling benchmark (benchmarks/lme4-scaling/) ranges from ~2× (small balanced LMMs) to ~5× (correlated random slopes on ≥30 grouping levels).
Non-goals (preserved)
-
mixeffis not a drop-inlme4replacement. - No bit-exact numerical reproduction of
lme4. - No model-selection or random-effects recommendation engine (no
recommend_model(),auto_random_effects(),fix_singularity(),make_it_converge()). -
lme4::lmer/lme4::glmerare not masked on attach.