Changelog
Changes are tracked separately in each repository: the ferx-core Rust engine (CHANGELOG.md) and the ferx-r R package (NEWS.md). Use the tabs below to switch between them.
Changelog
All notable changes to ferx-core are documented here.
Unreleased
Added
- A gradient at the solution for derivative-free fits, so
convergedis checkable on the runs where it matters most. When the outer optimizer supplies no gradient of its own —bobyqa, which is whatoptimizer = autopicks for ODE/PD, LTBS/SDE andgradient = fdmodels — ferx now computes a central finite-difference gradient of the same objective at the reported estimates and reports it asfinal_gradient, with a newfinal_gradient_sourcefield ("optimizer"or"finite_difference") saying which kind it is. Previouslyfinal_gradientwasNULLfor exactly the optimizer where a premature stop is most likely, soconverged = truewas unfalsifiable. The EBEs are re-solved inside the stencil, so it is the gradient of the marginal objective, not a fixed-EBE approximation. It is a reporting quantity only — it never steers the optimizer and the estimates are bit-identical either way — and costs2 × n_freeobjective evaluations, one gradient’s worth. New[fit_options]keyreport_final_gradient(defaulttrue) turns it off (#997). - A
stalled_at_initwarning when a fit never left its initial estimates — no free THETA, OMEGA or SIGMA coordinate moved — so the reported objective is the objective of the initial values and says nothing about the model. This most often arrives alongsideconverged: true, since a fit that never moved has a perfectly flat objective trace to plateau on, which is why it is its own warning code rather than a convergence one. The underlying predicate (stalled_at_init) already existed for model-selection strictness; it is now surfaced on every fit alongsideboundary_estimate(#997). CompiledModel::indiv_param_valuesandCompiledModel::indiv_param_value_map— read every[individual_parameters]value by name, at a given(theta, eta, covariates, time). This is the supported way to get an individual parameter’s value; the previousPkParams.values[pk_indices[i]]idiom maps to the PK slots the engine consumes and returns the wrong number for any analytical name that has no slot of its own. The map form drops the parser-internal__ferx_ro_*/__ferx_pktime_*parameters, so a consumer no longer has to carry its own copy of that prefix list. Both take theMIXNUMsubpopulation to evaluate under, so a mixture model can be read at a subject’s own fitted class rather than always at class 1 (#1356).- Per-parameter priors for penalized maximum-likelihood (MAP) estimation, declared inline as
prior(value, rse = 25%)on anytheta,omega,sigmaorkappa— the simple alternative to NONMEM$PRIOR, with no separate prior problem and no matrices. The fit reports the data and prior halves of the OFV separately plus a per-parameter shift-toward-prior summary; AIC/BIC stay on the data half, and the prior’s curvature reaches the reported standard errors and the SIR intervals. Applies tofoce,focei,laplace,gnandgn_hybrid; a chain whose last estimating stage cannot apply priors, and anS-based covariance estimator (covariance_method = sorrsr, neither of which can represent one), are refused rather than run unpenalized. The θ prior is anchored against NONMEM$PRIOR NWPRI(#254). [priors] from_fit = "<previous fit>"— build the priors for a model update from a previous ferx run in one line, instead of transcribing a parameter table by hand. Every θ, Ω diagonal, Σ and κ the source run reports with a usable standard error, and whose name and family match a[parameters]declaration in the new model, becomesprior(estimate, rse = SE/estimate)on the new model’s declared scale — variance-vs-(sd)conversions included. Reads{model}-fit.yaml,{model}-fit.jsonor a.fitrxbundle; a relative path resolves against the model file’s directory, like[data] path, and the file is read once when the model is parsed, so a bad path is refused byferx checkrather than surfacing mid-fit. Matching is on name and family, so a model carrying boththeta CLandomega CLimports each onto the right one. An inlineprior(...)on the same parameter wins, parameters that cannot be imported are listed in the fit’s warnings, and an import that lands nothing at all is refused rather than run unpenalized. Seeexamples/warfarin_update.ferx(#254).ParameterPrior::kindrecords which[parameters]family a prior was declared on, so aprior(...)still resolves in a model that reuses one name across two families (theta CLalongsideomega CL) instead of being refused as ambiguous (#254).- Logit-normal parameters are now mu-referenced (#918). A bounded
(0,1)individual parameter written asF = inv_logit(LOGIT_F + ETA_F),F = inv_logit(logit(TVF) + ETA_F), or the hand-writtenF = 1/(1 + exp(-(LOGIT_F + ETA_F)))is recognised as a mu-reference, so it no longer triggers the SAEM “individual parameter(s) not mu-referenced” warning. When the theta is declared on the logit scale (negative lower bound), SAEM and IMP/IMPMAP now also update it with the closed-form EM steptheta += gamma * mean(eta)instead of the numeric M-step — which is what fixes the biased bioavailability / fraction estimates reported for models with IIV on the residual error. A logit-scale theta declared with a non-negative lower bound is log-packed instead, so the closed form cannot apply; SAEM and IMP/IMPMAP then say so in a warning that names the theta and the bound to change, mirroring the existing advisory for a lognormal theta with a negative one. In a mixture model, a class-shared logit anchor (F = inv_logit(LOGIT_F + ETA_F), the same theta in every class) takes the closed-form shift too; aMIXNUM-switched typical value remains lognormal-only. - SAEM and IMP/IMPMAP now mu-reference covariate models that read several thetas (#619). A typical value such as
CL = (TVCL + (CRCL - 90) * TH_CRCL) * exp(ETA_CL),CL = TVCL * (WT/70)^TH_WT * exp(ETA_CL)orF = inv_logit(LOGIT_F + TH_SEX*SEX + ETA_F)has no single anchor theta, so all its thetas used to sit on the eta-frozen numerical M-step, where a covariate slope drifts to its bound (the fluconazole renal gradient of #619 landed on 0, 480 OFV units above NONMEM). The parser now records such a value as a covariate mu-reference and the EM estimators re-fit its thetas jointly to the population of individual values every iteration — exactly (Gauss–Newton) when the covariates are constant within each subject, no group theta is read elsewhere in the model and no other individual parameter reads the group’s eta, numerically (prior + data term) otherwise — the same thing NONMEM does for a MU written as a function of several thetas.mu_refs, inner-loop centring and every FOCE/FOCEI/Laplace fit are unchanged. A group that shares a theta with another eta’s anchor, has negligible IIV, is entirelyFIXed, or sits in a mixture model is declined with a warning and stays on the numerical M-step — and the “individual parameter not mu-referenced” advisory now fires for those declined groups, where it is true. A group whose typical value is not finite for some subject at the current θ (an additive form can go ≤ 0 for a low-covariate subject) stands down for that iteration, with a fit warning counting the iterations; under IMP/IMPMAP its thetas are no longer left frozen at their initial values when that happens. Anchored against NONMEMMETHOD=SAEMwithMU_1 = LOG(THETA(1) + (CRCL-90)*THETA(2))andMU_1 = LOG(THETA(1)) + THETA(2)*LOG(WT/70). - Mu-reference detection sees through local definitions (#918). A typical value on its own line (
TVCL = THETA_CL * (WT/70)^0.75thenCL = TVCL * exp(ETA_CL)) and NONMEM-style explicit mu syntax (MU_1 = log(TVCL)thenCL = exp(MU_1 + ETA_CL)) are now detected, provided the intermediate is assigned exactly once and carries no ETA.
Changed
SAEM now averages the residual sufficient statistic for eligible single additive and proportional error models, reducing final-draw Monte Carlo noise in the residual SD estimate (#1321).
method = laplaceno longer recomputes the sensitivity jet its grid anchor was just built from. The anchor and the½·log|H|derivative sweep needed the same evaluation at the same point; the anchor’s is now handed over instead of discarded, removing one of the sweep’s1 + 2·n_etaevaluations (7 → 6 on a 3-random-effect model). Debug builds assert the reused jet against a fresh evaluation, since a mismatched one would produce wrong derivatives rather than an error. Measured against the previous release on warfarin fixtures (ci-test,FERX_PROFILE=1): provider calls −9.1% on all three (diagonal Ω 4180 → 3800, block Ω 5280 → 4800, ODE 4300 → 3910), with OFV and outer-iteration counts unchanged in every case (#1344).SAEM’s per-occasion κ sampling now runs in parallel over subjects instead of serially beside the already-parallel η phase. Bit-identical: each subject writes only its own slots and draws from a generator seeded by
(seed, iteration, subject), so nothing depends on the order subjects are visited in. Applies to IOV models only; the phase’s share of a SAEM fit has not been profiled, so this removes a serialisation rather than promising a speedup (#1344).method = laplace’s analytic½·log|H|gradient term now sweeps only the random-effect axes, not every structural parameter axis as well. The assembly reads∂³f/∂η³and∂³f/∂η²∂θ; the θ-axis sensitivity evaluations existed to build two blocks only the covariance step consumes, so they were computed and discarded.∂³f/∂η²∂θis still obtained exactly, from∂/∂ηof∂²f/∂η∂θinstead of∂/∂θof∂²f/∂η²— the same mixed partial. Cost per subject per sweep goes from1 + 2(n_theta + n_eta)to1 + 2·n_etaevaluations: 13 → 7 on a 3-θ/3-η model, 33 → 9 at 12 θ and 4 η. Measured against the previous release on warfarin fixtures (5 interleaved reps,ci-test): provider calls −34 to −35% and provider time −35 to −38%; against the pre-#1335 finite-difference default, −39 to −58% calls and −36 to −59% time, with the ODE fixture’s whole-fit wall clock down 38% (1.90s → 1.18s). The two finite-difference directions differ at ~1e-10, so on an ODE model the optimizer path can move: the ODE fixture converges in 38 outer iterations instead of 37 and its OFV moves by 2e-4 (7e-7 relative), well inside the convergence tolerance. Closed-form fixtures were unaffected, andfoceiis untouched (#1342).[covariate_nn]models with IOV (kappa) now get the exact analytic FOCE/FOCEI outer gradient: the stacked[η, κ]sensitivity walk seeds the declared thetas, the random effects and one axis per network output, chains the weight columns in through backpropagation, and walks them in chunks — soautoresolves to L-BFGS instead of derivative-free BOBYQA over every weight, andgradient:reports analytic (#1339). This covers IOV models carrying an[initial_conditions]baseline, anobs_scaleexpression or an analytic Form C readout as well, and no longer caps theobs_scaleexpression’s(θ, η)width at 24.Calling a function ferx does not have is now a parse error naming the call and listing what is available, instead of silently evaluating as the identity.
CL = TVCL * tanh(ETA_CL)used to parse, passferx check, fit and converge while computingTVCL * ETA_CL. This applies to every block that parses expressions ([individual_parameters],[odes],[scaling],[derived],[error_model]magnitudes) and to conditions. Model files that relied on the no-op were already computing something other than what they read as, so the new errors are all true positives. Names remain case-insensitive, soEXP(...)/LOG(...)are unaffected;present(x)in value position now points at condition position (#1332).method = laplacenow assembles its½·log|H|gradient term analytically by default, instead of rebuilding the conditional Hessian atx ± hfor every free population parameter. Derivative-parity tests validate the two routes within numerical tolerances; benchmark OFVs agree at the console’s four-decimal precision. It was previously opt-in because the only closed-form fixture measured was a diagonal Ω, with similar call counts (14 FD rebuilds versus 13 provider evaluations) and timing results of opposite signs across sessions. On a block Ω, with a wider call-count gap, the analytic route measured 11520 → 8160 provider calls for −24% provider time on 5 of 5 reps at an identical outer-iteration count; on an ODE fixture, 10270 → 6470 calls and −35% time. On ODE models the optimizer’s path changes (the benchmark fixture converges in 37 outer iterations instead of 56), so converged estimates may shift within the convergence tolerance.FERX_AGQ_GRID_RESPONSE=fdrestores the old route for benchmarking, and the finite-difference sweep remains the automatic fallback wherever the analytic route is out of scope (#1335).
Fixed
A lagged dose arrival landing exactly on a covariate-changing record no longer returns an invalid η-gradient on an ODE model. The arrival is a moving boundary, but the covariate field jump at a record is stationary, and the analytic walk was attributing the second to the first — reading the pre-side velocity from the co-timed record and the post-side one from the next record at the same instant. The result was a
∂f/∂η_lagthat lay outside both one-sided derivatives (0.89 % beyond the nearer one, measured, first order, so it reached the FOCEI objective), for every observation after the arrival. Both sides now read one snapshot at all four onset sites — the bolus arrival, a lagged infusion’s rate-on, the shared built-in absorption onset and a per-routelag=onset — so the walk returns a genuine one-sided derivative: the branch its own event ordering implements, which for an arrival is the limit from below and for an infusion or zero-order window end is the limit from above. Predictions are unaffected (the term is jet-only), and a boundary interior to a record interval is bit-identical. The defect was invisible on a single-dose subject, where the compartment is empty at the arrival and the spurious term is multiplied by zero (#1068, epic #1350 row 19).The reported OFV is no longer worse than the objective the optimizer actually reached. After the outer loop restored its best-seen point, the final inner loop re-derived the empirical Bayes estimates from a cold start, while every evaluation during the fit had warm-started them. On a multimodal individual objective the cold restart settles at a different η̂, so the reported
ofv— and the AIC/BIC and covariance step built on it — came out above the value that made the point best-seen: +3.5 OFV on a fluconazole 2-cpt binding model, +6.96 on the FREM warfarin fixture. The final inner loop now also re-solves from the EBEs the optimizer minimised against and reports whichever set scores lower, and a newebe_start_dependentwarning names the gap when a cold re-solve lands materially worse, since that gap is a real statement about the model’s EBE surface. The convergence self-consistency check that compares a cold restart against the best-seen objective still reads the cold number, so it keeps rejecting warm-start-only “optima” (#833, #1349).A FREM inner-loop restart no longer resets the covariate etas to zero. When the inner BFGS did not certify convergence, the Nelder–Mead restart that re-centres a FREM subject started from a plain η = 0 vector — but a FREM covariate eta sits at
covariate − typical value, tens of units from zero, and Nelder–Mead’s initial simplex step at zero is 0.00025, so the restart could not travel there. It now starts from the same data-implied seed the cold start uses (cov_obs − TV), which is essentially that eta’s exact posterior mode. Non-FREM models are bit-identical (#1349).method = gn_hybridno longer reports afinal_gradientbelonging to the Gauss-Newton phase when the FOCEI polish is the result being reported. Whenever the accepted polish had no gradient of its own — reachable with a derivative-freeoptimizersuch as theautodefault on ODE/PD models, or the built-in BFGS — the merge kept the GN phase’s vector, sofit$final_gradientdescribed the pre-polish point while every estimate beside it came from after the polish. It is now the polish’s gradient or nothing (#997 review).A fit whose objective is not a usable number is no longer reported as converged.
fit()could returnconverged: truealongsideofv: NaN— measured on a population carrying one subject whose timeline could not be ordered, where theNaNspreads to the whole population’s objective.convergedis nowfalsewhenever the reported objective isNaN, infinite, or the clamped divergence sentinel, with a newW_NONFINITE_OBJECTIVEwarning (severityCritical, categoryconvergence) naming which of the three it is and what to look for.is_finite()alone was never enough: a repelled fit comes back at a finite~1e20sentinel, so the same cutoff the multi-start ranking uses is applied here. The rule is enforced at every place a(converged, ofv)pair is published —fit()and each estimator’s own result, so a tool driving an optimizer directly gets the same verdict — and the parameter estimates, per-subject diagnostics and other warnings are still returned, because they are what a user needs to find the offending record. The one exemption ismethod = viunder the defaultvi_final_ofv = none, which reportsofv: NaNdeliberately because the ELBO is not a −2 log L;vi_final_ofv = laplaceis gated like everything else (#1303).The IMP / IMPMAP / SAEM divergence verdict (#528) now says why: those methods already refused to call a runaway converged, but demoted the flag silently, so a caller saw
converged: falsewith nothing inwarningsdistinguishing it from any other failure (#1303).A fit that stopped for one reason and also has an unusable objective now reports both. Previously whichever was noticed first silenced the other, so a run that hit its evaluation budget was told only that — never that its objective was
NaNand every number derived from it meaningless. The two have different consequences (provisional estimates versus nothing usable at all), so both are reported (#1303).On an analytical (
pk ...) model, an[individual_parameters]name that the[structural_model]line does not bind — an intermediate such asTVCL = THCL * 3, or a modeled-doseD{n}/R{n}— is no longer reported asCL’s value. The sdtab[output]column, any[derived]expression that reads the name, and the ferx-rindividual_estimatestable all took the value from the name’s PK slot, and such a name has no slot of its own, so the lookup silently aliasedCL. Under the commonCL = TVCL * exp(ETA_CL)the two coincide at η = 0, which is why an sdtab eyeball rarely caught it; the bundledexamples/tte_exponential.ferxshowed it plainly, reportingLAMBDAasDUMMY_CL. ODE and compartment-free models were never affected. Values now come from the parameter’s own name via the newCompiledModel::indiv_param_values/indiv_param_value_map(#1356).A typical value used as the mu-reference anchor of more than one random effect (
F1 = inv_logit(LOGIT_F + ETA_F1)alongsideF2 = inv_logit(LOGIT_F + ETA_F2), or the lognormalCL = TVP*exp(ETA_CL)/V = TVP*exp(ETA_V)) is now estimated by the numerical / weighted M-step with a warning naming it, instead of taking one closed-form shift per random effect — which moved that theta twice in a single SAEM or IMP/IMPMAP iteration (#918).A
block_omeganext to a separate diagonalomeganow keeps the covariances between them at exactly 0, and so does ablock_kappanext to a separatekappa. Every estimator that runs the outer optimizer — FOCE, FOCEI, Laplace / AGQ,gnandgn_hybrid, with anyoptimizerincluding the trust region — searched the cross-block Cholesky entries anyway. Soblock_omega (ETA_CL, ETA_V)+omega ETA_KAestimatedCov(ETA_KA, ETA_CL)andCov(ETA_KA, ETA_V)and returned the full 3×3 block fit — same Ω, same OFV — whilen_parametersalready counted only the declared block. SAEM and VI already held these entries, and Gauss-Newton held them on its analytic gradient path but not on the finite-difference fallback that every IOV model (and M3, an η on the residual error, a θ-dependent error magnitude) takes. Estimates, OFV and AIC/BIC of any such fit change;n_parametersdoes not. Those entries now report an SE of exactly 0 (the covariance step already excluded them; the estimate itself no longer moves), and SIR and asymptotic uncertainty draws no longer perturb them — a SIR run on such a model also sees its Student-t dimensionality shrink by the held entries, so its weights and effective sample size move. For Rust callers,estimation::parameterization::packed_fixed_masknow marks these structural zeros as held along with FIX coordinates,pack_paramspacks them as 0, andcompute_boundspins them at[0, 0]where it previously returned the[-10, 10]off-diagonal box (#1018).iivsearchno longer warns that a mixed-ω candidate is fitted as a larger block than its description, because it no longer is. The note naming #1018, and theSpace::outer_full_triangleflag behind it, are removed, and a block-stage candidate with a block beside another η now ranks on the model it declares — on the Pharmpymoxonidineanchor the[CL,V]+[KA]candidate moves from 647.73 (fitted as the full[CL,V,KA]block) to 655.02, within an OFV unit of NONMEM’s own run of the declared model (#1018).The FREM docs (
docs/estimation/frem.qmd,docs/examples/frem.qmd) now call the R function by its current name,ferx_model_to_frem()(formerlyferx_to_frem()), passoutput_dirso the generated files are not written next to the model, and showprepare_frem()with its eighthfit_initargument.NPDE/NPD now sample the occasion
kappa. The post-fit NPDE/NPD diagnostics built their Monte-Carlo reference distribution with everykappaheld at zero, so for an IOV model the reference carried no between-occasion variability and the scores came back over-dispersed — a well-specified IOV model looked mis-specified. The reference now draws one independentkappa ~ N(0, Omega_IOV)per occasion, matching whatsimulate()already did. Anchored row-by-row against NONMEM$TABLE ... NPDE NPD ESAMPLE=(worst|dNPD|0.226,|dNPDE|0.280 across 540 observations). Non-IOV models are unaffected and their scores are unchanged (#734).A
pk(...)call that maps both spellings of one PK slot —v=/v1=(central volume),q=/q2=(inter-compartmental clearance),lagtime=/alag=(absorption lag) — to different values is now a parse error instead of silently applying one and discarding the other.pk one_cpt_iv(cl=CL, v=VA, v1=VB)previously parsed, fit, and returned predictions off by the ratio of the two volumes with no warning at all: the discarded parameter is mapped, so the “computed but never used” census counted it as used. Both spellings bound to the same value stays legal (#1048).A
[error_model]block with more than one plainDV ~ ...line is now rejected instead of silently keeping the first and dropping the rest. A model edited in place — a replacement pasted above the line it replaces — fitted against the old error model while reading as the new one, with no warning at any level. Per-CMT (CMT=N:) and covariate-selected (if/else) blocks are unaffected; both bind every line (#1022).Fixed the IOV inner loop discarding a converged-in-all-but-name BFGS solution for a far worse Nelder–Mead restart from the cold seed, which made every cold-started evaluation of a
kappamodel (the reported final OFV,outer_maxiter = 0re-evaluations,.fitrxreloads) score some subjects thousands of −2LL units above the value the optimizer had minimised — 9 300 on a[covariate_nn]+ IOV busulfan fit (#1327).Fixed ODE-accumulated survival hazards that read
TAD, which could reject valid multi-dose subjects with a misleading finite objective (#1261).floor(x),ceil(x)andround(x)now differentiate to0rather than tox’s own derivative. An[individual_parameters]or[odes]expression that rounds — a dose-band lookup, an occasion index derived fromTIME— was feeding a wrong analytic gradient to the estimator while its value path was correct (#1332).A
threadsbudget is now a real ceiling on how many fits run at once inbootstrap,modelsearch,covsearch,iivsearch,ruvsearchandglobalsearch. Each replicate or candidate runs its ownfit()on a nested thread pool, and a worker blocked on that nesting kept taking more work, so a run asked for 4 concurrent fits could hold far more — and that many fits’ worth of peak memory. The requested width is now enforced exactly. Runs that were already inside their budget are unaffected; heavily oversubscribed ones should see lower peak memory and steadier per-fit timing rather than higher throughput (#1329). Bootstrap’s unset thread budget preserves the ambient Rayon pool width, includingRAYON_NUM_THREADSand caller-configured pools (#1330).
Performance
Population fitting and prediction use the available worker budget more efficiently: Bayesian chains and underfilled AGQ grids run concurrently, small FOCE populations avoid fine-grained dispatch overhead, concurrent cold callers share pool construction, AGQ-IOV nodes avoid a heap allocation, and public
predict()evaluates subjects in parallel (#1385).FOCEI, Laplace, and
foceiwithn_agq > 1now build each subject’sEventScheduleonce per outer-loop evaluation instead of once per subject per AGQ node/gradient call.cacheable_schedulegates this on time-varying covariates orEVID=3/4resets (its guard is otherwise unchanged), so a fit with only baseline covariates and no resets never allocated a cacheable schedule in the first place and sees no change. Verified bit-identical OFVs againstmainacross every configuration tested. Measured onci-fast, single Rayon thread: no resolvable difference on the 24-subject/2-occasion-per-subject Schnider propofol fixture (tests/schnider_propofol_nonmem.rs), where schedule construction is a small share of per-eval cost; a synthetic 40-subject/15-occasion-per-subject stress fixture (built to amplify per-subject schedule-construction cost) showed a modest, directionally consistent ~3–6% wall-clock reduction across FOCEI, Laplace, andfocei+AGQ(n=3). No claim is made beyond that stress scenario (#1345).FOCE, FOCEI, Laplace, and AGQ now reuse prediction and prior-matrix storage across repeated conditional-likelihood evaluations. AGQ also caches invariant Hermite rules; IOV quadrature borrows occasion effects and uses the covariance inverses already cached in the model parameters instead of copying and refactorizing them at every node (#1374).
ODE AutoSwitch now reuses accepted RK45 stages to detect stiffness without extra right-hand-side evaluations and carries its verdict history across dose and covariate event boundaries. The dimensionless runtime signal is independent of the model’s time unit; the periodic Jacobian probe remains as a backstop (#1371).
Laplace and AGQ gradient callbacks now reuse the anchor and quadrature grid computed for their matching objective evaluation, instead of rebuilding both per subject; objective-only quadrature sweeps also reuse one node-coordinate buffer rather than allocating at every node. Optimizer choice and numerical results are unchanged (#1370).
The post-fit per-subject diagnostics pass (IPRED / PRED / IWRES / CWRES, per-subject OFV) and the post-fit analytic-sensitivity sweep now run in parallel over subjects on the pool the fit already uses, instead of one subject at a time. Output is unchanged bit-for-bit and the ODE-solver diagnostic counters are unchanged; the gain is on the final pass of a fit with many subjects, and is largest on ODE models (#1329).
foceiwithn_agq > 1now contracts the analytic grid-response gradient term once per subject instead of once per population parameter. The node gradients, weights and node positions do not depend on which parameter is being differentiated, so both node sums hoist out of the coordinate loop, taking the contraction fromO(p·Q·d²)toO(Q·d² + p·d²)forpfree parameters,Q = n_agq^dnodes anddrandom effects. The one-node grid additionally skips the node-displacement term and the fived×dmatrix products behind it outright, since its nodes sit atz = 0— that arm is now reached by the default analytic route forlaplace(#1335). No wall-clock figure is claimed: this reduces the contraction around theQnode-gradient evaluations, not their number, and those dominate on ODE models. Gradients move only by floating-point reassociation — measured worst relative change1.7e-15, a few ULP — so converged estimates and OFVs may shift within the convergence tolerance (#1333).focei, n_agq > 1(the Gauss-Newton-anchored FOCEI quadrature refinement) now assembles its½·log|H̃|grid-response gradient term analytically instead of rebuilding the anchor atx ± hfor every free population parameter.H̃is bilinear in first-order prediction sensitivities, so unlike the exact-anchor Laplace case its derivative needs no third-order jet — one extra ordinary analytic-provider evaluation replaces2·n_freeperturbed-anchor rebuilds. The analytic and finite-difference routes agree to within the finite-difference route’s own truncation error (and this is on by default); converged estimates, OFVs and standard errors may move within the convergence tolerance since the optimizer trajectory itself changes (e.g. an ODE fixture converges in 37 outer iterations instead of 53); measured on warfarin fixtures: 30–50% fewer analytic-provider calls, and on an ODE model roughly 2× less provider time and ~30% faster wall-clock, converging in fewer outer iterations. Also covers custom/time-varying σ magnitude,iiv_on_ruv(including combined with an M3-censored row), M3-BLOQ including its σ-direct derivative, correlated residuals (block_sigma), and IOV (the stacked[η, κ]system, via a dedicated joint-prior assembly) — only mixture models keep the pre-existing finite-difference route.laplace/AGQ’s exact-Hessian anchor gained the same analytic route for closed-form and ODE models, initially opt-in viaFERX_AGQ_GRID_RESPONSE=analyticand now the default after the block-Ω benchmark described above (#251, #1335).Closed-form steady-state bolus models with estimated lag times now use analytical event sensitivities, avoiding finite-difference fallback for the supported event-walk route (#1311).
The closed-form inner EBE gradient no longer computes or allocates work it discards. Three redundant-work sites in the FOCE/FOCEI/Laplace analytic sensitivity path, none changing the objective, gradient formula or optimizer trajectory:
- The closed-form log-normal fallback (used when a model’s compiled
[individual_parameters]program doesn’t cover every required PK slot) called the fullθ/second-order derivative builder and then read only its η-block; it now calls a light η-only counterpart that skips the θ-axis and second-order work (and the FDtv_theta_jacobianpass those needed) entirely. - The per-observation
∂f/∂ηwalk (run_obs_grad, run on every inner BFGS step) allocated a freshVec<f64>per observation on every call; it now reuses a per-subject scratch buffer threaded from the inner loop across BFGS iterations, since neither the observation count norn_etachanges within one EBE solve. - The per-observation residual endpoint keys (
ErrorSpec::obs_keys, non-trivial only for aSelected/covariate-selector error spec) were recomputed every inner BFGS step; they are now hoisted once per subject, the same treatment the custom-magnitude multiplier already got. The dense-residual (block_sigma) branch had the equivalent problem for that multiplier itself — it never received the caller’s already-computed value — and is fixed the same way.
Verified bit-for-bit unaffected: the full
cargo test --libsuite (4453 tests) is green, and two new regression tests pin the light derivative against the full one and the scratch-buffer reuse against a corrupted hint. No wall-clock figure is claimed here — these are allocation/redundant-computation removals with an unchanged provider call count, not a change to what gets evaluated (#1373).- The closed-form log-normal fallback (used when a model’s compiled
Added
Additive (
+) covariate effects in[covariate_model](#1313). A trailing operator token makes a relation a term added to the parameter instead of a factor on it —CL ~ WT linear(center = 70) +desugars toCL = TVCL * exp(ETA_CL) + THETA_CL_WT*(WT - 70).*stays the default. ferx’s additive template drops the leading1the multiplicative one carries, so θ = 0, a covariate at its centre and a missing covariate all mean “no effect” (the missing-value guard iselse 0.0under+,else 1.0under*). Multiplicative and additive relations may be mixed on one parameter, and an additive relation carries no top-level-product requirement. Mu-referencing switches off for a parameter with an additive relation — the typical value is a sum — and the parser warns. This is the last MFL operator:COVARIATE(..., +)is no longer a search coverage gap, andferx covsearchexploresCL-WT-linear-addas a candidate of its own. Note: Pharmpy reuses the multiplicative template under+, so a model translated from Pharmpy will not reproduce its equations;ferx searchsays so on any space that asks for+.categorical2— a second[covariate_model]categorical form, Pharmpy MFL’scat2(#1312).categorical2(ref = r)contributesθ_kat each non-reference level and1at the reference, wherecategoricalcontributes1 + θ_k. Same degrees of freedom (one θ per non-reference level) and an exact reparameterization —θ_cat2 = 1 + θ_catgives the same OFV on the same data — so it is a choice of how θ reads, not a cheaper test: the θ is the multiplicative factor itself (θ = 1.3→ “30% higher”) and bounded below at0, where1 + θwithθ < −1can turn the parameter negative. Defaults are the image ofcategorical’s under that map: init0.999, bounds(0, 6)— the bounds Pharmpy uses verbatim. Note the null moves with the form:fix = 1is “no effect” forcategorical2wherefix = 0is forcategorical. Search spaces now resolveCOVARIATE?(CL, SEX, cat2)instead of reporting a coverage gap.ferx globalsearch— global model search with pyDarwin’s genetic algorithm or exhaustive enumeration, ranked on pyDarwin’s penalized fitness (#1185, P6 of #1175). The.ferxsearchspace is laid out as one grid — every structural category an axis with its values as alleles, everyCOVARIATE?pair an axis withnoneand each of its forms — and searched globally:[globalsearch] algorithm = "exhaustive"fits every point,"ga"runs a seeded genetic algorithm (tournament selection, one-point crossover, mutation, elitism, fitness sharing, a periodic one-gene downhill search; every knob under[globalsearch.ga]).[rank] type = "penalized"is now implemented for every search tool: OFV + 10 per estimated θ / Ω / σ element + 100 for non-convergence, a failed or absent covariance step, a parameter correlation above 0.95 or a condition number above 1000, with[rank.penalties]overlaying any charge. The global search charges three more things the criterion cannot see — a gene that changes nothing in the rendered model, a candidate that cannot be built, and a fit the strictness gate refused — so an unselectable model steers the search without winning it. Candidates go through the same runner, journal and canonical-hash dedup as the stepwise tools;--resumeon the seeded GA refits nothing.models.csv,generations.csv,final.ferxand every candidate undermodels/are written;docs/tools/global-search.qmdsays when a global search beats the stepwise tools and when it does not.An initial estimate that lies outside its own optimizer bounds is no longer clamped in silence (#1251). A
thetawhose start is strictly outside the range it declares is now refused before any fitting (E_THETA_INIT_OUTSIDE_BOUNDS) — until nowtheta TVCL(0.05, 0.1, 10.0)quietly fitted from0.1, a factor of two, on every run; NM-TRAN refuses the same stream outright (error 24). A start outside one of ferx’s internal rails instead — the hidden1e9theta cap, theomega±6/ off-diagonal±10guards, thesigma[-8, 5]guard — is aW_INIT_OUTSIDE_BOUNDSwarning, carrying the newinit_outside_boundswarning category. Both are reported byferx checkwithout a--datafile, and both shareE_OMEGA_INIT_AT_RAIL’smaxiter = 0exemption. A start sitting exactly on a bound is left alone: there the clamp is a no-op, so nothing is moved. The new category is deliberately distinct fromboundary_estimate, which is about where a fit ended and which drivesbootstrap’s replicate filter andreject_on_boundary.Analytical covariance R matrices now cover in-scope
[odes]models. FOCE, FOCEI, and FOCEI-anchored AGQ reuse the existing augmentedDual2ODE sensitivity solve and obtain the required third-order prediction blocks by central differences of that second-order jet, matching the closed-form covariance design. The ODE step accounts forode_reltol; IOV and M3 censoring can be combined. Exact-anchor Laplace remains on the reconverged finite-difference covariance path because its marginal requires fourth-order prediction derivatives (#436).ferx amd— the automatic model development pipeline (Pharmpyamd) inferx-tools(#1184). One.ferxsearchfile drives every search tool of the epic in turn: structural → IIV → residual → IOV → allometry → covariates by default, withreevaluation,SIR,SRIandRSIreordering the same components ([amd] strategy, Pharmpy’s own spellings accepted). The file’s one[space]is partitioned before each step, somodelsearchsees onlyABSORPTION/PERIPHERALS/TRANSITS/LAGTIME,covsearchonlyCOVARIATE, and aCOVARIANCE(*, ...)is narrowed to its IIV half foriivsearchand its IOV half foriovsearch— every tool refuses a foreign statement by name, which is what a single-space pipeline would otherwise run into.[rank]is narrowed the same way: the criterion goes to the steps that rank on it, and the two likelihood-ratio steps (ruvsearch,covsearch) keep their own p-value thresholds. Each step starts from the model the last one selected, seeded from its estimates;[amd] retries(all_final/final/skip) says which selected models get a perturbed-restart pass at[run] retries + 1starts. A step the space says nothing about — or an IOV search on a model with noiov_column— is skipped with its reason recorded, never run on a space invented for it; a step whose tool errors is reported as failed and the pipeline carries on from the model it was handed, exiting 1. The report is the product:steps.csvhas one row per planned step with the criterion before and after, the ΔOFV and the wall clock;candidates.csvhas every candidate of every step with its Δ against its parent, the strictness verdict with its reasons, whether the fit converged and what it cost; and the printed summary ends with the final model’s estimates and standard errors. The sequencing and the space split are anchored against Pharmpy 2.2.0’s ownamd— every strategy’s order, and the subspacemodelsearchandcovsearchare handed for a corpus of spaces — with two deliberate divergences asserted as differences: ferx skips a step whose space is silent where Pharmpy substitutes a default search space, and resolves aLETagainst the model the step starts from rather than at parse time. Seedocs/tools/amd.qmdandexamples/amd_start.ferxsearch.ferx modelsearchsearches non-linear elimination and the ODE absorptions (ELIMINATION(ZO / MM / MIX-FO-MM),ABSORPTION(ZO / WEIBULL)) (#1257). These four MFL values have no analyticpktemplate and were refused by name at config load; they are now generated asode_template NAME(...)candidates with one[odes]line replacing thecentralequation, so the disposition, the compartment count, a transit chain and the covariate model all come along unchanged. Parameterisation and initial estimates follow Pharmpy (CLMM·KM·C/(KM + C);KMatmax(DV)/2, or fixed atmin(DV)/100for zero-order elimination; a zero-order input duration of2·MATwithMAT = 2·t_first; a Weibull scale ofMAT / Γ(1 + 1/β)atβ = 1.5), except that the Michaelis-Menten clearance keeps the base model’s ownCLname — and with it its estimate and its η — where Pharmpy renames it toCLMM.ABSORPTION(SEQ-ZO-FO)is still refused: it is a depot of its own, not one input term on a standard disposition. Bioavailability and the lag time are carried through every move, including a second one off an ODE parent, and the Michaelis constant’s observation range is floored positive — Pharmpy resets a negativemin(DV)/100to0.01, and ferx applies the same fallback to a zero minimum and to a non-positivemax(DV), sinceKM ≤ 0is singular. Because these candidates cost an order of magnitude more per fit, the runner now plans candidates of equal cost together, heaviest group first (so an ODE candidate gets subject-level threads instead of running alone on one worker), and a saturable elimination is fitted with at least 8 starts — a floor under[run] retries, never a replacement — because a stalled Michaelis-Menten fit ranked against a converged first-order one rejects a correct model on the strength of the optimizer. Seedocs/tools/modelsearch.qmd.ode_template NAME(...)variants in the public API (#1257).ferx_core::pk::ode_template::generate_variantwrites the same generated disposition with the central compartment’s input and elimination terms replaced (InputForm,EliminationForm), andferx_core::edit::StructuralSpec::ode(...)makes a structural edit write it — theode_templateline plus the override — instead of apkline.SetStructuralnow swaps in both directions, clearing the[odes]block when a candidate moves back to an analytic template.Analytic IOV and M3 covariance for FOCE, FOCEI, and FOCEI-anchored AGQ — include all occasion effects, differentiate shared IOV covariance blocks once, and carry censored normal-tail curvature using each method’s own marginal definition (PR #955).
Analytic covariance (standard errors) for FOCEI-anchored adaptive Gauss-Hermite quadrature —
method = foceiwithn_agq > 1now derives its R-matrix analytically instead of finite-differencing the objective function. The finite-difference stencil it replaces costs~2·n_free²reconverged population objectives, each of which sweeps the wholen_agq^dnode grid for every subject; the analytic assembly is a single pass. Standard errors are unchanged in meaning — they still describe the quadrature marginal the fit actually minimised, not the FOCEI one.method = laplaceis unaffected and keeps the finite-difference covariance: it anchors on the exact conditional Hessian, whose second derivative would need fourth-order sensitivities. Models outside the analytic covariance scope (non-Gaussian endpoints) also keep the existing path, and a poorly identified fit falls back rather than reporting an ill-conditioned analytic result. The quadrature anchor is taken directly from the objective assembly, avoiding an inverse round trip (#251, PR #955).ferx iivsearch— variability-structure search (Pharmpyiivsearch) inferx-tools(#1183). From a.ferxsearchfile whose[space]names the η to search (IIV?([V,KA], EXP); a plainIIV(CL, EXP)keeps that η) and the correlations to try (COVARIANCE?(IIV, [CL,V,KA])): the number of η undertop_down_exhaustive,bottom_up_stepwiseorsimultaneous_stepwise, then the block structure undertop_down_exhaustive— one candidate per full block among the retained η, plus the diagonal model — each step ranked with its parent on the BIC(iiv) (OFV + n_ω·ln(n_subjects), whatbicmeans for this tool) behind the strictness gate, and the final model compared with the input. Every candidate is written by the η / block edits from its parent’s estimates; a new block starts at the parent’s EBE correlations and a block over three or more η gets[iivsearch] block_retriesextra starts per η. A parameter outside the canonicalP = TVP * exp(ETA_P)form is refused by name before anything is fitted. Anchored against Pharmpy 2.2.0 through NONMEM 7.5.1 on a simulated dataset: fortop_down_exhaustiveandbottom_up_stepwise, the same candidates in the same numbering, the same winner at each step and the same final[CL,V]at the same BIC(iiv).simultaneous_stepwiseagrees on the first step and then parts from Pharmpy, because its[CL,V]+[KA]candidate is a mixed block + diagonal ω, which FOCE/FOCEI fit as the full block (#1018, open): ferx selects that candidate where Pharmpy selects[CL,V]. The search notes #1018 on any such candidate, and the anchor asserts the divergence so the fix turns it red.ferx iovsearch— inter-occasion variability search (Pharmpyiovsearch) inferx-tools(#1183). A model with a κ on every candidate parameter (IOV?([CL,V], EXP)in the[space], or every parameter with a free η by default), then every subset of the optional κ removed — including all of them when a plainIOV(CL, EXP)keeps one, since the model left is not the input — ranked with the input on the BIC(random); then, from the winner, every subset of the η that sit beside a κ removed. The κ are declareddisjoint,joint,same-as-iivorexplicit(groups), each at a tenth of its η’s fitted variance, and the base must read its occasions itself (iov_column). Anchored against Pharmpy 2.2.0 through NONMEM 7.5.1 on a three-occasion dataset: the same candidates, the sameIOV([CL])winner at the same BIC(random), the same final model.ferx-core::editgrows the κ edits and a structure reader (#1183).ModelEdit::AddIov/DropIovwrite and remove a κ in the canonicalP = TVP * exp(ETA_P + KAPPA_P)form,SetKappaBlock/SplitKappaBlockandSplitOmegaBlockblock and unblock κ and η, andDropIiv/DropIovnow shrink a block around its survivors instead of refusing (Pharmpy’sremove_iivon a joint distribution).VariabilityText::readreports which parameter carries which η and κ, how they are blocked, which areFIX, and whether each line is in the canonical form.SeedInitscarrieskappaandblock_kappaestimates too, and the search seed floors a collapsed κ as it floors a collapsed ω.IOV(...)andCOVARIANCE(IOV, ...)are searchable MFL features (#1183), in the exponential form; the coverage table says so, andCandidate::startslets a search tool ask for more starts on one candidate than the run’s default.ferx ruvsearch— residual-error model search (Pharmpyruvsearch) inferx-tools(#1182). From a.ferxsearchfile with no[space]— the candidates are the four residual-error forms: IIV on the residual error, apowerform, acombinedform and a time-varying magnitude cut at thei / groupstime-after-dose quantiles — each added to the parent on its own, fitted in parallel with retries and the strictness gate, and kept when the likelihood-ratio test at[ruvsearch] p_valuesays so; an input that is not plain proportional is first refitted as one, and the final model must beat the input by thedf = 1cutoff or the input is returned, as in Pharmpy.cwres_prescreen = trueis Pharmpy’s own path: the candidates are fitted to the parent’s CWRES first and only the winner is refitted. The step table shows every model’s ΔOFV, p-value, convergence status and decision;models/<id>.ferxholds every candidate as fitted. Anchored against Pharmpy 2.2.0’s own run through NONMEM 7.5.1 on a simulated power-residual dataset: the same CWRES screening dOFVs (to 0.6), the same pick, the same refit OFV, the same final model.power(σ, P)residual-error form (#1182).DV ~ power(PROP_ERR, RUV_POW)is NONMEM’sY = F + EPS(1) * F**THETA(n): the proportional loading raised to an estimated θ, so the variance isσ²·|f|^{2P}andP = 1is the proportional model. Every estimator, IWRES, CWRES and simulation carry the exponent; the analytic FOCE/FOCEI and Gauss-Newton gradients carry∂R/∂P(pinned against finite differences). Anchored against NONMEM 7.5 on the warfarin dataset, to 1e-6 on the OFV.TADin residual-magnitude expressions (#1182). A[error_model]magnitude may readTAD, the data-derived time after dose with Pharmpy’sadd_time_after_dosegrouping (steady-state aware, no lag time; a trough at the dosing time belongs to the previous dose, a pre-dose sample to dose group0), besideTIME—proportional(PROP_ERR * (if (TAD < 12.0) RUV_TV else 1.0))is Pharmpy’s time-varying residual error. A model that also declares aTADcovariate is rejected rather than reading two differentTADs.ferx-core::editauthors and reads back the residual-error features (#1182).ErrorSpecTextgainedexponent,iiv_on_ruvandtime_varying(built withErrorSpecText::newand thewith_*builders; the struct is now#[non_exhaustive]),ErrorForm::Power, andErrorSpecText::read, which turns a model’s[error_model]back into authoring form and refuses one it cannot represent.SetErrorModeldeclares the θ / ω a feature needs and prunes the ones the previous error model alone referenced.ferx modelsearch— structural PK model search (Pharmpymodelsearch) inferx-tools(#1181). The space is theABSORPTION,PERIPHERALS,TRANSITSandLAGTIMEstatements of a.ferxsearchfile; every candidate is one analyticpktemplate swap from its parent, with the new parameters declared from the parent’s estimates (Pharmpy’s inits:Q = CL,V2 = 0.05·Vc, a lag or mean transit time at half the first observation time) and η on the absorption delay by default (iiv_strategy). Pharmpy’s three algorithms —reduced_stepwise(default),exhaustive_stepwise,exhaustive— and its incompatible-pair rules, with the enumeration anchored against Pharmpy 2.0.0’s own workflow. Candidates are fitted in parallel with retries and the strictness gate, ranked on[rank] type(mixed BIC by default) with an optionalcutoffover the base; the table shows every model’s structure, criterion, rank, convergence status and fit time, an excluded model with its reason.ELIMINATION(ZO / MM / MIX-FO-MM)is refused by name rather than offered as[odes]candidates — the decision and the coverage table are ondocs/tools/modelsearch.qmd. Anchored against NONMEM 7.5 on the warfarin dataset: same OFVs, same BIC ranking.NewParameter::fixedinferx-core::edit(#1181).SetStructuralcan declare a new parameter’s θFIX— a fixed transit-compartment count stated as a named parameter rather than a literal binding, so the ODE twin and the estimates file still see it. Breaking for struct-literal construction:NewParametergained the field and is now#[non_exhaustive]; build one withNewParameter::new(name, theta, init, lower, upper)and the.with_iiv(...)/.fixed()builders, which makes the next field addition non-breaking. Its fields stay public to read. No effect on.ferxmodels, the CLI or the R wrapper, none of which constructs it.
Changed
BREAKING (pre-1.0 minor bump, 0.3.1 → 0.4.0):
CovariateFormgained a variant and is now#[non_exhaustive](#1312). AddingCovariateForm::Categorical2to a public enum breaks any downstream crate thatmatches it exhaustively — the code compiles against 0.3.1 and fails to compile against 0.4.0 withnon-exhaustive patterns: CovariateForm::Categorical2 not covered. Migration: add a_ => …arm (or aCovariateForm::Categorical2arm) to anymatchonCovariateForm. Nothing else changes: variants are still constructible, the serde representation of every existing variant is unchanged, and.ferxfiles,FitResultand sdtab are untouched. The enum is now#[non_exhaustive], so the_arm is required from here on and the next form — level grouping — will be genuinely additive.fit()now refuses athetawhose initial estimate is strictly outside its own declared range (#1251). It previously accepted the model and clamped the start onto the bound, so a model file that fitted before now stops withE_THETA_INIT_OUTSIDE_BOUNDSbefore the first objective evaluation. No model shipped with ferx is affected — the exact predicate over every.ferxin the repository finds none — but a model file of your own with a mistyped bound will now be reported instead of quietly fitted from somewhere else. The comparison is against the declared numbers, sotheta TVCL(-5.0, 0.0, 10.0)is caught even though the start and the declared lower bound both pack onto ferx’s internal1e-10floor, and the message names where the fit really begins (1e-10, which is neither the declared value nor the declared bound).maxiter = 0runs are exempt, as forE_OMEGA_INIT_AT_RAIL.
Fixed
- The
{model}.tmpcheckpoint written by a deterministic stage (foce,focei,laplace,gn,gn_hybrid) now stores the best point that stage has reached, not whichever evaluation happened to be running when the write interval elapsed (#1317). The objective is evaluated at every point the optimizer probes, so a write landing mid-line-search recorded a throwaway trial point: on a[covariate_nn]FOCEI fit plateaued at OFV 51786 the checkpoint held OFV 2.76e6. Resuming from such a file restarted the fit from the probe, and anything reading the checkpoint as “where the fit is” (a resume, a progress monitor, a scorer) saw a point orders of magnitude off. For these stages the storediteris now the evaluation at which that best point was seen. Asaemstage is unchanged: it saves its latest state (with that iteration’s conditional NLL asofv), which is what a correct continuation of the chain resumes from — so a consumer comparing checkpoints must readmethod_chain/stage_idxfirst. cov_inner_tolno longer reports that it is ignored for estimators whose covariance step applies it (#956). It is now a framework-level covariance key likecovariance_methodandfd_hessian_step, so every current and future estimator that runs the covariance step accepts it. A fit whose last estimating stage isbayesruns no covariance step, and now says so for all six covariance keys — “configures the post-fit covariance step … has no effect” — instead of the misleading “not used by methodBayes” (#956).cov_inner_tolnow rejects a non-positive or non-finite value at parse time, asfd_hessian_stepalready did (#956). Such a value used to parse and then silently make every covariance-step EBE reconvergence exhaustinner_maxiter.- The analytic ODE sensitivity walk no longer injects a rate-off boundary term for a
CMT=0infusion, whose rate it never turns on (#1077).CMT=0is NONMEM’s default dose bolus compartment and has no meaning for a zero-order input, so both predictors drop such a row andcheck_dose_compartmentsrejects it outright (E_DOSE_CMT_NOT_INFUSABLE) — but when the dose also carried a lagtime the gradient walk still fired the infusion-end saltation, reporting a finite∂f/∂η_LAG(+1.89 at the first sample past the window end, against a central-difference reference of exactly0.0) for a subject that receives no drug and predicts0.0everywhere. Reachable only from a hand-built model spec that runs no validation; no validated fit changes. The compartment test is now one shared predicate (dosing::infusion_has_rate_channel), asked by every site on either engine that turns a rate on or off: four of the walk’s rate-on sites spelled it inline ascmt_raw() >= 1and the rate-off saltation at the infusion-window end asked nothing at all. - A
thetawhose declared range cannot be represented no longer aborts the fit (#1251).theta TVCL(1.0, 5.0, 2.0)— bounds swapped — andtheta TVCL(1e-12, 1e-13, 1e-11)— an ordinary small parameter whose whole range falls below ferx’s internal1e-10packing floor — both produce an empty optimizer box, and the bound clamp panicked on it. ferx now reportsE_INIT_BOUNDS_INVERTED, naming which of the three causes applies. It is the one start-side check with nomaxiter = 0exemption, because an evaluation-only run clamps the start too. Only the affected coordinate is silenced, soferx checkstill reports the rest of the file in the same pass. - The
W_INIT_OUTSIDE_BOUNDSmessage for asigmanow says which scale its numbers are on (#1251). ferx stores σ as a standard deviation and square-roots a plainsigma X ~ vdeclaration, so the quoted number is an SD that need not appear in the model file:sigma PROP_ERR ~ 1e6now readsan SD of 1.000e3rather thana value of 1.000e3. - A fit no longer stops on its first evaluation and reports every parameter at its initial value (#1290). The outer loop’s EBE warm-start cache adopted the empirical Bayes estimates of every evaluation, including the ones the line search rejects, so a single bad trial step left the inner loop in a worse basin and the starting point itself re-evaluated worse than before the excursion — an objective a line search cannot descend, which NLopt reports as a bare
Failure. The cache is now anchored to the best point seen. Models with covariate thetas were the visible casualty, and with them everycovsearch/modelsearch/iivsearchcandidate that differs from its parent by one added parameter;examples/two_cpt_oral_covmodel.ferxgoes from OFV -1026.35 at its initial estimates to -1195.30, andexamples/two_cpt_oral_cov.ferxfrom -1168.48 to -1199.33 (NONMEM FOCEI: -1199.43). - A subject whose timeline cannot be ordered — a
NaNor infinite dose time, lagtime, route lag or infusion duration — is now reported instead of being silently indistinguishable from a subject with nothing to integrate (#1234). The prediction engines abandon such a walk before calling the solver, which left every counter in theode_solverdiagnostic at zero: measured on one model, a non-finite timeline, a subject with no records, and a subject with a single observation att = 0all readattempted/accepted/rejected = 0/0/0while returning[NaN, …],[]and[0.0]respectively. A newabandoned_non_finite_timelinecounter onOdeSolverStatsseparates the first from the other two, and a fit that hits it now emits anode_solverWarning naming the count and what to check, where before it returnedofv = NaNwith no warning mentioning the subject, the timeline orNaN. The counter reports walks, not subjects (a subject whose predictions and[odes]state readout are both requested contributes more than one), and it rides in the warning’sdetailspayload.ode_predictions_with_solver_statsreports it too. Theode_solvermessage no longer ends by recommending a differentode_methodor looser tolerances when the only thing that went wrong is an abandoned walk: nothing was integrated, so no solver setting changes the outcome, and the message now says that instead. - Deeply saturated, over-capacity steady-state input-rate models no longer let Anderson acceleration report a huge spurious periodic state when integration error hides the positive per-cycle surplus (#867, PR #955).
- Quadrature S/RSR covariance rejects unavailable subject scores instead of differentiating the optimizer’s population EBE penalty. Numerical fallback is local to each subject, preserves fitted η/κ warm starts, honors
cov_inner_tol, and skips zero-weight covariance nodes; analytic Hessians use a deterministic parallel reduction (#955). - Quadrature
covariance_method = s/rsrnow use scores of the selected AGQ objective instead of FOCE/FOCEI scores. Mixture FOCEI rejects unsupportedn_agq > 1, Rust API calls rejectn_agq = 0, and incomplete AGQ derivatives fall back to full-objective finite differences instead of omitting terms. An explicit AGQ likelihood readout preserves the preceding estimator’s method label as well as its parameters and covariance, and later quadrature stages cannot bypass the IOV grid-size limit (#955). - A non-finite dose attribute is now caught at every dose record, not only at the subject’s
TIME = 0baseline (#1235).E_DOSE_ATTR_NONFINITEevaluated$PKonce per subject — at the baseline covariate row, atTIME = 0— while the engine resolves the attribute per dose. Both arguments were wrong, along two independent axes, and each let a broken model through to an all-NaNfit with no diagnostic: a time-varying covariate that is benign at the first dose and overflows at a later one (ALAG1 = TVLAG*exp(WT)), and a lag reading theTIMEbuilt-in on a subject with no covariates at all (ALAG1 = TVLAG*exp(TIME)). Both are now rejected up front, naming the subject and the record. The check also covers the modeled infusion durationD{n}and rateR{n}behind a codedRATE(#324), whose failure was quieter still: nothing validated their value, and a non-finite one never reaches you as aNaNat all — an infiniteD{n}givesrate = amt / D = 0, so the dose was served as an instantaneous bolus, and aNaNis clamped to a floor with the same effect. The fit returned finite, silently wrong numbers: measured on a 1-cpt ODE (CL/V = 0.1,V = 10, one 100-unit coded-RATEdose), within 8.2e-7 of the exact bolus solution10·e^(−0.1t)and 1.90x high against the correct infusion att = 1. - The built-in absorption domain and pathway-fraction checks are evaluated at every record’s snapshot (#1235).
E_ABSORPTION_DOMAINandE_ABSORPTION_FRACTIONread the same frozen baseline snapshot; the engine rebuilds the input-rate forcing per segment from the last event’s snapshot, so anmttor pathway fraction driven out of range only at a later observation is a value it really does apply. The set is the engine’s own record predicate — dose, observation and EVID=2 rows — and not EVID=3/4 resets, whose segment is discarded by the re-seed; azero_order()window, fraction and per-route lag are checked at dose records only, because a spanning window is fixed at dose time rather than rebuilt per segment. Both narrowings are what stop an ordinary crossover or washout dataset, and an ordinary covariate-drivenzero_order(dur=DUR), from being rejected for a value the engine never reads. Behaviour change:predict()andsimulate()enforce this pair as a panic (they run no data check, but do callassert_absorption_dosing_supported), so a model that previously returned numbers from an out-of-domain forcing now aborts there instead.E_DOSE_ATTR_NONFINITEhas no such twin and still does not reachpredict()/simulate()— that gap is #1280 / #898, not something this change closes. The two checks deliberately read different snapshot sets: a lag /F/D{n}/R{n}is read at dose records only, so one going non-finite at an observation is not an error. W_MODELED_DURATION_NONPOSITIVE/W_MODELED_RATE_NONPOSITIVEare evaluated per dose record too (#1235). These warn when a modeledD{n}/R{n}is≤ 0at the initial estimates, and read the same attribute out of the same per-dose snapshot asE_DOSE_ATTR_NONFINITE— but they were still frozen at one(baseline covariates, TIME = 0)point per subject, so a duration that only collapses at a later dose went unwarned. Whether≤ 0should be an error rather than a warning, and what the mid-fit clamp does to the trajectory, remains #1284.W_STEADY_STATE_INFUSIONno longer claims a record is served as a single non-SS infusion when it is not (#1281). The warning compared the record’s ownAMT/RATE; the steady-state run-in compares the length after bioavailability, which on a rate-defined infusion isF · T_inf(Fscales the length, the data having fixed the rate) and on a duration-defined one (RATE=-2→D{n}) is the duration untouched. So a bioavailability below 1 could pull a nominally overlapping infusion back underII, and the run-in then ran while the warning said it had not. Measured on a 1-cpt ODE model withAMT=100, RATE=5, II=12: atF1=1.0the predictions are finite and the warning is right; atF1=0.5the same record predictsNaNon aTAFD-reading right-hand side — which only the run-in can produce — and the warning was wrong. Both steady-state warnings now ask the integrator’s own predicate, so they agree about which doses reach the run-in. ARATE=-2record’s verdict is unchanged.CWRESis now NONMEM’sCWRES(#1182). It was each residual divided by its own marginal SD,(y − f0) / √R̃ⱼⱼ. NONMEM’s conditional weighted residual (Hooker et al. 2007) is the decorrelated vectorR̃^{-1/2}(y − f0)with the symmetric inverse square root ofR̃ = HΩHᵀ + R,Revaluated atIPREDunder an interaction fit and at the population prediction under FOCE — rebuilt from NONMEM’s own tabledG,ETA,IPREDandPREDon a 40-subject oral dataset with sizeable η, that recipe reproduces its column to an RMS of 1e-4, where the old column was off by 0.75 whileIPREDagreed to 1e-4. The two agree only with no η, so a model without random effects is unchanged (CWRES = IWRESthere, as before); censored rows stayNaNand are left out of the decorrelation. A CWRES-based screen such as ruvsearch’s pre-screen picks differently on the old column, which is how this surfaced.- A search child seeded from a parent whose ω block is near-singular can start (#1256). On the vancomycin base the (CL, V1, V2) block’s Cholesky diagonal for
ETA_V2sat on the optimizer’s rail (6e-6) through its correlations, every declared variance being ordinary; the three-compartment candidate seeded from it was refused by every start. The shared seed now nudges that block alone by1e-5·Iuntil its factor clears the rail, alongside the diagonal floor; a standalone ω beside it and aFIXed block are untouched, and a block that cannot be repaired within the bound goes through verbatim. - “All multi-start fits failed” now carries each start’s reason (
start 0: …; start 1: …), so a search table says why a candidate never fitted — a refused start is a different repair from a diverged fit. ferx modelsearchnever selects the input model (#1181). The input has a row of its own only when a base had to be derived from it — which happens exactly when its structure lies outside the space the MFL declares — so it is ranked in the table but excluded from selection;final.ferxcan no longer be a structure the space excluded.- Numbers the edit layer writes are rounded to 15 significant digits (#1181). An estimate that went through the optimizer’s log/exp packing comes back one ULP off —
10.000000000000002for an evaluation at10.0— andSeedInitsused to write that verbatim into a candidate orfinal.ferx. Fifteen significant digits keep every value a user could type and drop the noise; what is read back differs from the estimate by at most one part in 10¹⁵. - A search child is seeded off a collapsed variance at the smallest startable one (#1181). A parent fit whose η collapsed to the optimizer’s rail (
ω ≈ 6e-6) used to hand every child seeded from it a start the engine refuses (“starts at a variance … at or below its lower bound”), so a search step failed outright; covsearch and modelsearch now floor the seed at1e-5, the same no-variability model spelled so the child can move off it. The floor is on the child:SeedInitsitself still reproduces the fit it is given, so a writtenfinal.ferxre-evaluates to the OFV in thefinal-fit.yamlbeside it. ferx covsearch— stepwise covariate modelling (PsNscmforward / forward-then-backward, Pharmpycovsearch) — andferx allometryinferx-tools(#1180). The first shipped model-space search tool: the candidate effects come from theCOVARIATE?(...)statements of a.ferxsearchfile, forcedCOVARIATE(...)effects go into the base model first, each step fits its candidates in parallel with retries and the strictness gate, and the winner is the largest OFV drop that is significant by the likelihood-ratio test atp_forward; the backward phase removes the cheapest effect whose removal is not significant atp_backward. Adaptive scope reduction (SCM+) andmax_stepsas in Pharmpy; each child starts from its parent’s estimates. The step table shows every candidate’s ΔOFV, degrees of freedom and p-value beside its convergence status and strictness verdict — an init-stalled candidate is excluded with the reason, never selected on an OFV that says nothing about the model. Anchored against PsN 5.7.1 + NONMEM 7.6 on the same dataset and relations: same trajectory, same final relation set, OFVs within 1e-4 (docs/tools/covsearch.qmd).ferx allometryadds(WT/70)^0.75to every clearance and(WT/70)^1.0to every volume thepkline binds — as[covariate_model]lines, fixed or estimated — and fits base and scaled model side by side.DefaultonUncertaintyMethodandSimulateUncertaintyOptions(#529). The uncertainty-simulation options can now be built with..Default::default()(UncertaintyMethod::Asymptoticis the default method), so wrapper code stays source-compatible when a field is added. Every other*Optionstype inferx-coreandferx-toolsalready implementedDefault; a new inventory guard (tests/public_api_boundary.rs, A4) now discovers every*Optionsdeclaration undersrc/andcrates/and fails on one that lacks it, so the convention holds for options types added later..ferxsearchsearch configuration and an MFL search-space parser inferx-tools(#1179). A TOML file (base,data,[space] mfl,[rank],[strictness],[run]) whose space is written in Pharmpy’s Model Feature Language, with@IIV/@PK/@CONTINUOUS/ … resolved against the base model’s[individual_parameters],pkline,[covariates]block and the dataset. A feature ferx cannot build (ELIMINATION(MM),ABSORPTION(SEQ-ZO-FO), the PD families, …) is a hard error naming it, never a silently narrowed search; the coverage table is indocs/tools/search.qmd.- Optional regularization for the covariate NN (
[covariate_nn]/ DCM) (#1215). Via two new[fit_options]keys,nn_l2andnn_smooth(both non-negative, default0.0= off — a strict no-op that keeps existing fits byte-identical).nn_l2adds L2 weight-decay (Σ wᵢ², weight matrices only, biases free);nn_smoothpenalizes the finite-difference 2nd derivative (curvature) of each output along every input’s marginal partial-dependence curve, damping the high-frequency wiggles a high-capacity DCM invents on a null covariate structure. The curvature grid is built in the network’s own(x − center) / scaleinput space and spans every per-record covariate snapshot a time-varying input takes (not just its baseline), so it smooths the curve the fit actually evaluates over the range it is actually evaluated on. Both feed the optimizer a penalized objective with matching analytic gradients (the smoothness term reuses the MLP’s analytic Jacobian — no autodiff) and Hessian terms across the FOCE-family methods —foce/focei/laplaceunder every outer optimizer, andgn/gn_hybrid; a non-FOCE final stage (SAEM, IMP, Bayes, VI) warns that the keys are not applied. The reportedofv/AIC/BIC, the optimizer trace, the checkpoint and the verboseEval/Iterlines remain the unpenalized −2·log-likelihood so DCM-vs-analytic model comparisons stay valid;final_gradient, multi-start /gn_hybridphase ranking and the convergence gates use the penalized objective the fit actually minimised. Settable identically from the model file andferx_fit(settings = list(nn_l2 = ..., nn_smooth = ...)).
Fixed
- An observation within
1e-12after a lagged dose arrival is no longer read pre-dose (#1226). With a compartment or route lag whose arrival lands a few ULP short of a sample time — reachable wheneverALAGis estimated or covariate-scaled, since an optimizer walks it continuously — the objective,sdtaband the dense grid behind the joint PK-TTE hazard,[derived]integrals andsimulate()recorded that sample from the state before the dose was applied, so a subject read drug-free at a sample taken after its own dose (45.38 against NONMEM’s 145.38 on the committed anchor — a whole 100 mg, on the OFV and not only a diagnostic). The mirror sign was wrong in the opposite direction on one path: the shared PK-TTE solve’s cumulative-hazard boundary read used a symmetric tolerance, so a hazard time just before an arrival was overwritten with the post-dose state. Recording is now one-sided everywhere — at or up to1e-12after a break reads post-event, anything before it reads pre-event — matching NONMEM’s record ordering, which is anchored on both signs (nonmem_anchor/lag_arrival_read_{before,after}_advan{1,13}). The event-driven predictor and the analytical closed forms were always correct and are unchanged. The same fix closes three pre-existing gaps found while making it: an observation landing exactly on an interior dose read pre-dose on the analytic-sensitivity walk while the predictor read post-dose; a dose landing on a subject’s last observation was never applied by that walk at all (the FOCEI gradient short by a whole dose while the objective had it); and an observation coinciding with a dose break was assimilated twice by the SDE/EKF filter, returning an over-confidentp_obsat that record and a distorted covariance for the rest of the subject. - A joint PK-TTE subject whose
TENTRY(or interval-censored left bound) falls at or before its first record no longer scores the1e20sentinel (#1223). The one-solve shared path left such a time’s ODE stateNaN, which the TTE likelihood reads as a diverged solve; the dedicated two-solve path filled it with the seeded initial state, so whether a subject was repelled or scored depended on which engine it was admitted to — a question of resets and time-varying covariates, not of where its entry time falls. Both engines now agree withpredict_survival():H = 0andh = h(u₀)there, so a pre-start entry time contributes nothing. - The boundary-estimate warning no longer fires on an interior estimate of a θ with a wide, asymmetric range (#1180). An identity-packed θ (lower bound below zero) was judged by its position as a fraction of the declared range, so with PsN’s
scmdefaults(-100, 1e6)— what every[covariate_model]power/exponentialθ carries — an estimate of 0.7 read as “pinned to the lower bound” and the strictness gate excluded every covariate candidate. It is now judged by its distance to each bound on that bound’s own scale. - A dose landing within 1e-12 of a derived break time is no longer applied twice (#1186). A per-route absorption onset (
dose.time + ALAG + lag) and an infusion end (dose.time + AMT/RATE) are multi-term float sums, so they routinely land one or two ULP from another dose’s own break — past the timeline’s 1e-15 dedup and inside the dose-arrival match. Every engine that resolves its events by rescanning the timeline then applied that dose at both breaks: a bolus was doubled (144.04 against NONMEM’s 144.041725 on the anchor fixture, and 239.11 against 170.73 further out), and a colliding infusion was activated twice so its rate doubled for the whole window. Every engine now fires each dose event exactly once, and the dose / SS-seed / reset match is one tolerance everywhere. It used to be 1e-12 on the objective path and 1e-10 on the sdtab, joint PK-TTE hazard,[derived], Markov andsimulate()paths, so the same dataset could double a dose in every diagnostic while the reported OFV was correct — and an infusion doubled on only those paths at any separation. Reachable without any absorption DSL (any infusion whose computed end nears a later dose), through a large time value, an optimizer iterate driving an estimated lag toward zero, or a covariate-scaled lag. New anchor:nonmem_anchor/break_collision{,_inf}.ctl. - A non-finite dose lagtime no longer panics the fit (#1189). A
NaNor infiniteALAG/LAGTIME— typically an exponential covariate model on an unscaled covariate — made the subject’s integration timeline unorderable and aborted withcalled Option::unwrap() on a None valueon the objective path and both dense builders. Such a subject now comes back non-finite, which the estimator already handles as a diverged solve, and a lagtime orFthat is already non-finite at typical values is rejected before the fit starts withE_DOSE_ATTR_NONFINITE, naming the subject. (The previous “NaN-safe” sort spelling was not safe either: its comparator is not a total order, whichsort_bypanics on when it notices — which it does only for some timelines, so that spelling was neither safe nor reliably loud.) - A joint PK-TTE (or binary / Markov) model fed a population read without the model is now a hard error instead of a silently wrong fit (#1199).
read_nonmem_csv()knows no model, so a dataset read through it carried the endpoint’s rows as Gaussian observations and no event records;fit()then ran the Gaussian half only and reported a plausible, finite, wrong objective,predict()returned a concentration for the event row, andsimulate()drew no events.fit(),simulate()andpredict()now reject that population withE_ENDPOINT_UNROUTED(naming the CMT andread_population_for()), andfit()/ferx checkreject a routed population whose declared endpoint has no rows at all — typically a missingCMTcolumn — withE_ENDPOINT_NO_RECORDS. The same guard coverspredict_categorical(),run_covariance()andrun_sir(); the last two now also re-readfit.data_pathrouted by the model (the path the R wrapper’sferx_covariance()/ferx_sir()take), instead of computing the covariance step or SIR on the Gaussian half of a joint likelihood. The.fitrxreload (load_fit, used by the CLI) routes the bundled data by the bundled model too, so a reloaded joint fit keeps its event records. - A steady-state (
SS=1) dose on an[odes]right-hand side that readsTADnow returns the periodic steady state instead ofNaN(#1139). The steady-state run-in handed the compiled right-hand side a parameter array two slots shorter than the one it reads the model-time anchors from, soTADevaluated toNaNfor the whole run-in and poisoned every later prediction — including when the term’s coefficient was zero, since0.0 * NaNisNaN, so merely mentioningTADbroke an otherwise ordinary steady-state model.TADis now anchored to each run-in window’s own pulse: the exact one-cycle solve, the capped pulse train, the quiet window of a steady-state infusion, and the built-in-absorption train, which advances its anchor per cycle. Measured against NONMEM 7.6.0 on a 1-cpt IV bolus (CL = 1,V = 20,II = 12): ferx8.8902009677against NONMEM8.8902010334(7.4e-9), and against a closed form computed outside both engines, 3.0e-10.TAFDandTIME/TunderSS=1are unchanged — they have no periodic steady state to converge to, andTIME/Talready matched NONMEM. SS=1combined with a lagtime on aTAD-reading[odes]right-hand side now predicts correctly (#1126).TADhad no referent in the window between the dose record and the lagged arrival, so every prediction of such a model wasNaN— and because the record-time steady-state seed flows to the arrival rather than being re-equilibrated there, the whole subject was affected and not only that window. Since #1121 the state there is real: it is the previous cycle’s decaying tail, whose pulse landed atdose.time − max(II − ALAG, 0), and that is whatTADnow measures from — on both ODE predictors, in thesdtabTADcolumn (previously blank there) and in any[derived]/[output]expression readingTAD. A lagtime of a full interval or more keeps #1121’s clamped phase, soTADruns0 … ALAGacross the window rather than wrapping. The referent is the one the analytical superposition has used since #1121, so the two engine families now agree; an ordinary (non-steady-state) lagged dose is unchanged. Measured against NONMEM 7.6.0 on a 1-cpt IV bolus (CL = 1,V = 20,II = 12,ALAG1 = 3): ferx5.5452940851inside the window against5.5452941786from NONMEM’s explicit 41-dose lagged train (1.7e-8), and 4.7e-10 from a closed form computed outside both engines. NONMEM’s ownSS=1record is not the reference here — it sits 1.3e-2 from its own train on this model, where the same pair on an autonomous right-hand side agrees to 1.1e-9. This replacesE_SS_LAGTIME_TAD_RHS, which was added earlier in this same unreleased cycle and never shipped: the combination is served rather than rejected, on every path includingsimulate()andpredict().
Changed
- The unused-fit-option warning is model-aware for the ODE solver keys (#518). Setting
ode_reltol/ode_abstol/ode_max_steps/ode_method/ode_stiff_abort_after/ode_auto_switchon a model that never integrates — analytical PK with no[odes]block and no closed-form absorption ODE twin — now warns that the key has no effect, instead of being dropped silently. Models that do integrate (including a closed-form transit / inverse-Gaussian model reaching its twin) still never warn on these keys, as of #517. - A free variance declared on the optimizer’s lower rail is now rejected up front (#1229).
omega ETA_CL ~ 0.0withoutFIX— and any freeomega/kappa/[mixture] omega(k)variance ≤ 6.1e-6, since all of them pack toln(L) ≤ -6— fails withE_OMEGA_INIT_AT_RAILfromfit()and fromferx check, naming the parameter and the one-keyword fix. A declared zero is regularised to1e-8, so its packed start (-9.21) sits below its own lower bound and is clamped onto the rail; from there the coordinate stays collapsed or runs away to the opposite rail, and the θ estimates move with it (48% off on the #1227 fixture) whileconvergedis a coin flip. NM-TRAN refuses the same stream with error 76. Write~ 0.0 FIXfor no variability, or start at ≥ 1e-5 to estimate it.sigma ~ 0.0is unaffected — measured to reach the optimum from its own-8rail — andpredict()/simulate()are untouched, so a zero-variance fixture used only for prediction still works. The check applies only when an optimizer will actually search:maxiter = 0(NONMEMMAXEVAL=0, as used byferx gam --no-fitand byferx-tools’ bootstrap--dofv, which re-evaluates each replicate at its own estimates) is exempt — it clamps the start like any other run, but produces one objective and stops, so nothing is trapped on the rail. Note that it therefore evaluates a free~ 0.0atexp(-12)rather than at the declared value, whichFIXavoids (#1251).saem/imp/impmap/bayescarry their own iteration counts and are checked regardless. A near-singularblock_omega/block_kappais reported as the correlation problem it is, rather than as a small variance. - FREM prep refuses a model with a non-Gaussian endpoint (#1199).
prepare_frem()/transform_dataset_for_frem()returnE_FREM_NON_GAUSSIAN_ENDPOINTinstead of writing a dataset from the Gaussian rows alone; run the FREM step on the PK model without the endpoint block. block_sigmanow estimates its off-diagonal correlation (#847). A plainblock_sigma (...) = [...]is NONMEM$SIGMA BLOCK(n): its diagonal SDs and its off-diagonal correlation are estimated. Previously the correlation was always frozen at the declared value, so a model with a$SIGMA BLOCKcounterpart in NONMEM optimized a different objective — on the fluconazole RadboudUMC model NONMEM moved the residual correlation to ~0.93 while ferx held the ~0.2 init. AppendFIXto hold the whole block — SDs and correlation alike, matching$SIGMA BLOCK(n) FIX. Note that the old behaviour (SDs estimated, correlation frozen) is no longer expressible: NONMEM has no such form either, and it was the mismatch this issue is about. The shippedexamples/correlated_residual_combined.ferxalready usesFIXand is unaffected. The correlation is optimized as its Fisher-z transformatanh(rho), so it stays strictly inside(-1, 1)and the residual covariance can never go singular from the correlation alone, and it rides the exact analytic FOCE/FOCEI outer gradient rather than falling back to finite differences. Estimators that do not estimate it (SAEM, IMP/IMPMAP, Bayes, AGQ, VI) continue to hold it at the declaration. Because a method chain starts each stage from the previous stage’s estimates, a chain that runsfoce/foceibefore a stage that cannot read the estimated correlation is now rejected withE_BLOCK_SIGMA_CHAIN_UNSUPPORTEDrather than silently scoring the declared value —[saem, focei]is fine,[focei, imp]needsFIX.
Performance
[covariate_nn]models with a time-varying network input are analytic on both FOCE/FOCEI loops. The event-driven sensitivity walk no longer counts the generated weight thetas against its dual-width cap: it seeds the declared thetas, etas and one axis per network output, chains the weight columns in per event through the network’s backprop Jacobian, and walks the theta columns in chunks. Subjects that used to fall back to reconverged finite differences (~300× per objective evaluation on the vancomycin DCM) now take the exact gradient; models without a network are numerically unchanged. The network forward pass and backprop also drop their per-callnalgebramatrix rebuilds for plain slice loops (bit-identical outputs), and the chunked walk evaluates each event’s PK values once per subject rather than once per chunk (#1300).- Generic analytical FOCEI gradients with exactly four or six differentiated PK parameters, one or two of them IIV-bearing, now omit the unused Hessian block among IIV-free parameters (#829).
- Allocate per-event PK scratch storage only when the prediction path needs it, reducing allocation traffic for static-model FOCE/FOCEI fits (#1283).
- IOV inner optimization reuses per-event PK parameter buffers across likelihood probes, reducing allocation traffic while recomputing every event at the current parameters, covariates, time, and occasion (#104).
- The main FOCE/FOCEI optimizer paths combine EBE solves with subject scores; mixed analytic/FD gradients use one subject pass. This reduces synchronization between inner and outer optimization and avoids EBE-buffer copies (#1115).
- Reuse worker pools for repeated fits and
PoolPlanbatches. Explicitly sized fits retain independent budgets; unpinned fits with identical ODE overrides share one persistent pool instead of multiplying worker counts. Idle retention is bounded across thread counts and solver settings, with one most-recent wide pool retained (#1115, #1212). - A joint PK-TTE model with a linear PK block now takes the exact steady-state solve (#1210). The hazard accumulator’s one-cycle map is the identity, so it made
I - Msingular and the exact(I - M)^-1 bfixed point (#914) declined for every joint model, whatever its PK block looked like — the equilibration ran the full 50-cycle pulse train, and could emit a spuriousSteady-state (SS=1) equilibration ... did not convergewarning for a PK block that had settled long before, since the accumulator never stops growing. The accumulator rows are now projected out of that solve, so a linear PK block gets its handful of one-cycle integrations back.
Added
- A shared candidate runner for model-space search (#1178, part of #1175).
ferx_tools::search::Runnerfits a list of candidate models in parallel and returns each one scored: the ranking criterion (ofv,aic, or any of the four BIC variants), theStrictnessverdict with the reasons for every gate it failed, and the fit itself. Candidates are identified by the canonical hash of their model text, so a model reached twice by two different edit paths is fitted once; with a cache directory each outcome is journalled as it finishes, so an interrupted overnight search resumes and refits only what is missing, and every candidate — including the ones that failed to compile, failed to fit or failed the gate — appears incandidates.csvwith its reason rather than being dropped. The thread budget splits across both levels of parallelism viaPoolPlan(#1115) instead of nesting Rayon pools, and aCancelFlagstops a search between candidates and returns the partial results — intocandidates.partial.csv, so cancelling a resumed run never overwrites the complete table of the run it resumed. The cache directory is claimed for the length of a run (search.lock) because two runs sharing one would silently destroy each other’s journal, and a directory that cannot be written costs the resume and the report rather than the fits: those failures come back onRunReport::warningswith the results intact. A lock whose owner was hard-killed is taken over automatically, so resuming after a kill — the case the journal exists for — never needs a file deleted by hand. A candidate that produced no fit carries aCandidateErrorsaying whether the failure was the model (it does not compile: remembered, and reported without refitting) or the run (a fit pool that could not be built: refitted on the next resume), so one bad minute cannot permanently mark a fittable model as unfittable. This is the orchestration layer the covariate, structural, variability and residual-error searches of #1175 are built on. - BIC variants and a
Strictnessgate for candidate ranking (#1177, part of #1175).ferx_core::bic(&result, BicType::{Mixed, Iiv, Random, Fixed})computes the four conventions ofpharmpy.modeling.calculate_bicfrom a finishedFitResult— the Delattre-style mixed BIC penalises random-effects-class parameters onln(n_subjects)and the rest onln(n_obs), which is what Pharmpy’siivsearch/modelsearchrank on. The class tally is recorded on the newFitResult::bic_inputs(and round-trips through.fitrx; older bundles readNaNrather than a wrong penalty).check_strictness(&result, &Strictness { .. })evaluates the pyDarwin-style gates — convergence, covariance step, condition number, parameter correlation, boundary estimates, and the #751 init stall — and returns the named reason for every failed gate, so a search report can say why a candidate was excluded.FitResultalso gainsleft_init, the outer optimizer’s own init-escape verdict, whichstalled_at_initprefers to its natural-scale comparison, andomega_is_diagonal/kappa_is_diagonal, the packed Ω / κ layout the correlation gate needs to read ablock_omegaon the natural scale (all three round-trip through.fitrx).bootstrap’sskip_estimate_near_boundaryand its covariance-step tally now use the sameestimate_near_boundary/require_covariancepredicates, and its replicates no longer run thecovariance_fallback = sirpass. - Fitted
block_sigmacorrelations are reported with their fixedness and standard error (#847).FitResultgainsresidual_correlation_fixedandse_residual_correlations(the SE on the naturalrhoscale, by the delta method on the packed Fisher-z coordinate), the fit YAML’sblock_sigma:section gainscorrelation_seand now reportscorrelation_fixedfrom the model rather than alwaystrue, and.fitrxbundles round-trip all three. A bundle written before this change loads with every correlation marked fixed, which is what it meant at the time.
Fixed
Numbers written to JSON now reload as themselves, bit for bit (#1178).
serde_jsonparses floats with a fast algorithm accurate only to within 1 ULP unless itsfloat_roundtripfeature is enabled, which it now is acrossferx-core,ferx-toolsand the CLI. Anything that writes a number and reads it back — a.fitrxbundle, a search journal, a cached fit — could otherwise return an estimate one bit from the one that was computed, and do it invisibly, since every printed form rounds long before that digit. The case that caught it was a resumed candidate search reporting a criterion of-200.28784144636057for a fit that scored-200.28784144636055.n_parameters, AIC and BIC no longer count the structural zeros of a mixedblock_omega+ diagonalomegaas estimated parameters (#1177). The cross-block Cholesky entries of such an Ω are pinned, never searched, and the covariance step already excluded them; the information criteria counted them anyway, inflating the penalty by one per structural zero.n_parametersnow equalsCompiledModel::free_packed_dim().predict_survivalreturnedNaNfor a time grid with no point past the first event (#1218). Asking for the curve at[0.0]alone — or any grid whose largest time does not pass the subject’s first dose or observation — returnedNaNforcum_hazardandhazardwith no warning, while the samet = 0on a longer grid was fine. It now returns the state at that instant, post-dose, exactly what the longer grid reads. The same one-break timeline reached two other readers of the dense state:[derived]output columns on the event-driven path (time-varying covariates, resets, or a model-time read) wereNaNfor a subject whose only observation coincides with its dose, and a joint PK-TTE subject outside the shared single-solve (same routing) whose only event or censor sits on its first dose scored the1e20sentinel instead of its finite likelihood. Both fixed by the same change.ODE solver settings passed to
fit()now reach the solver (#1212).ode_reltol,ode_abstol,ode_max_steps,ode_method,ode_stiff_abort_afterandode_auto_switchwere stamped onto the compiled model at parse time and read from there by every integration path, so the same keys set on aFitOptionshanded tofit()were silently ignored: the fit ran at the model file’s (or the default) accuracy, on the default stepper, and reported success. Tightening a tolerance to test an integration-noise hypothesis returned a bit-identical objective across six orders of magnitude, and a caller selectingrosenbrock23/rodas4/rodas5pfor a stiff system stayed on the explicit stepper with nothing to say so. A fit now carries the caller’s ODE settings to the integrator for the duration of that fit. Precedence is per key and one-directional: a key the caller moved off its default wins, a key left at its default yields to the model file, so passing a hand-builtFitOptionscannot loosen a model that pinnedode_reltol = 1e-10. The same now applies to the standalonerun_covariance(),run_sir()andrun_sir_core()entry points, which previously ignored a caller’s ODE settings — so a covariance step run beside a tight fit no longer differences a coarser surface than the estimates came from. The model-file route,predict()andsimulate()are unchanged; note that the override lasts one call, sopredict()after a tightfit()on the same model still uses the model file’s accuracy unless yousync_ode_solver_optsan owned model. Concurrent fits are isolated from each other: a call’s settings travel on the thread that made it and on a thread pool keyed to those settings, so a fit that asked for nothing keeps the model file’s accuracy even while another fit runs at1e-10beside it (and vice versa) — which matters forferx-tools’ parallel replicate fits and for any caller sharing the fit pool.An
SS=1dose no longer carries the steady-state run-in into a joint PK-TTE model’s cumulative hazard (#1210). The appendedd/dt(__chz_<cmt>)accumulator was cycled through the equilibration along with the PK compartments, but it is a pure integrator with no steady state — soH(0)came back holding the run-in’s own hazard (H0 x 50 cycles x II, e.g.12.0for a constantH0 = 0.02atII = 12) and every survival quantity downstream of it was displaced by that amount.S(t) = exp(-H(t))made this fatal rather than cosmetic: on a drug-driven hazardS(0)underflowed to0and each subject’s objective was inflated by ~2500, and a simulatedSS=1subject drew its event att = 0in every draw.SS=1now equilibrates the PK compartments only and the accumulator keeps its value at the dose record —0for a subject’s first dose, and, for a laterSS=1dose, the hazard accrued so far (an SS dose re-loads the compartments; it is not a reset —EVID=3/4still are). A lagged SS dose no longer banks its phase advance either, which also removes a non-monotoneH. A hazard readingTAD/TAFDunderSS=1returnedNaNfor the whole subject and now works.An estimated
block_sigmacorrelation is bounded at|rho| <= 0.995(#847). Merely keeping rho inside(-1, 1)is not enough: a paired residual block’s determinant carries a factor1 - rho^2, so a rho of 0.9999 leavesRnumerically singular and the likelihood will chaselog|R| -> -inf. On the 12-observationexamples/correlated_residual_combinedfixture an unbounded rho ran to -0.99993 and reported convergence at a degenerate optimum. A rho sitting on this rail is now a legible diagnostic: the two endpoints are carrying the same noise.A zero
block_sigmaoff-diagonal is now estimated rather than dropped (#847).block_sigma (A, B) = [0.04, 0.0, 1.0]— the natural translation of$SIGMA BLOCK(2)with a zero covariance init — previously built no correlation at all, so under the new estimate-by-default semantics it would have silently fitted a diagonal residual with no coordinate to move. AFIXed zero is still dropped: a fixed zero correlation is the same object as no correlation.method = laplace/n_agq > 1with a freeblock_sigmanow uses the reconverged-FD outer gradient (#847). AGQ’s analytic score assembles theta / omega / sigma / omega_iov and never writes the rho slot, while its objective does depend on rho — so the analytic gradient is declined for a free correlation rather than handing the optimizer a hard zero there. AFIXed block keeps the analytic score. The chain guard was widened to match:laplacecan move rho, so[laplace, imp]is rejected exactly like[focei, imp].The
block_sigmacorrelation coordinate is no longer magnitude-scaled below 1 (#847). The outer optimizer’sabspreconditioner divides each packed coordinate by its own|value|, which is right for a log-space coordinate but meaningless for the Fisher-zatanh(rho)— that is a position in a bounded range which passes through zero. At the commonrho = 0.2init it handed the optimizer a coordinate with roughly thirty times the scaled room of every other one, and NLopt L-BFGS failed on its first step. The scale is nowmax(|atanh rho|, 1). On the fluconazole RadboudUMC model a cold-start FOCEI fit goes from failing at OFV 1111.12 to converging at 736.89 with rho = 0.9319, against NONMEM’s 734.644 / 0.9312. Models without ablock_sigmaare unaffected by construction.simulate()drew correlated residuals at the declaredblock_sigmacorrelation (#847). The dense residualRit samples from used the live sigmas but the model’s declared correlation, so a VPC or posterior-predictive check of a fit with an estimated off-diagonal would not reproduce the correlation the fit reported. It now uses the parameter vector’s.block_sigmaresidual derivatives were built at the declared correlation, not the live one (#847). The outer-gradient assembly (corr_residual_diag/corr_residual_rd_at_sigma) and the inner eta-gradient read the correlations off the frozenCompiledModel, so once the off-diagonal became estimable the whole(R, dR/df, d2R/df2)chain would have been evaluated at the initial value while the objective moved. Both now take the live parameter vector’s correlations.ode_method = autono longer keeps a stiff solve whose analytic derivatives have overflowed (#1204). The escalation guard checked that every saved state was finite, but read values only. A dual number’s derivative jets carry higher powers of what its value carries linearly — integratingu' = p·ugives∂u/∂p = t·uand∂²u/∂p² = t²·u— so a trajectory near the top of double precision overflows its Hessian first, its gradient next, and its predicted value not at all. Such a segment reported success, clamped nothing, finished cleanly, returned finite predictions, and handed FOCE/FOCEI aNaNgradient with every counter reading zero. The guard now also rejects on non-finite jets and re-solves the segment explicitly, and the same check scores the explicit fallback, so a fallback that did not repair the gradient is reported instead of being presented as a successful retry. The newauto_stiff_rejected_jetscounter is reported in theode_solverwarning with its own advice — the stiff method worked and the sensitivities did not, so naming another method will not help; check the model’s units and scaling. A namedode_methodstays unguarded, as before. To make the counter observable at all, the post-fit diagnostic sweep now also runs one analytic sensitivity solve per subject for models on the analytic ODE sensitivity path — the second-order provider where the model has an analytic outer gradient, since the Hessian is what overflows first, and nothing at all for an FD fit. Thef64prediction pass carries no derivatives and could never see this decision, so that sweep is collected separately and only the new counter is reported from it. ##### ChangedVI early stopping is now judged with robust statistics. The settling test compared the mean of the last window of the objective trace against the mean of the one before it, and sized its tolerance from the trace’s own sample variance. Both estimators have a breakdown point of zero, so on a heavy-tailed trace — what an unhealthy VI run emits — a handful of outliers inflated the spread until the tolerance swallowed the drift that was still there, and the run reported
convergedat the earliest iteration arithmetically allowed. The criterion now uses the median and the MAD (scaled by 1.4826) in the sameSETTLE_Z · spread + rel_tol · (1 + |location|)form, so a tail can no longer buy a premature stop. Estimates on healthy fits are unchanged:propofol_schniderandvancomycin_uvm, which stop on the parameter-stability criterion, are identical down to the last reported digit, andwarfarin,two_cpt_oral_covandwarfarin_iov, which stop on the trace, agree to four or five significant figures. The trace criterion is slightly more conservative, so those three run longer for the same answer (5125 → 8375, 6625 → 7250 and 3500 → 3625 iterations). A noiseless trace still settles on the relative floor alone (#1119). ##### Addedferx_core::edit— a typed model-transformation API, so a program can now write a.ferxmodel as well as read one (#1176).ModelText::parse/renderround-trips a model file byte for byte (comments, alignment, blank lines and line endings included), andModelText::applyapplies one typedModelEdit: swap the structural model, add or drop a[covariate_model]relation, add or drop an η, block two ηs together, change the residual error model, carry a parent fit’s estimates into the child (SeedInits, keyed on parameter names, not positions), or set a[fit_options]key. A structural swap performs the coupled θ/η/expression edits across all three blocks —two_cpt_oral→one_cpt_oraldropsQ,V2,TVQ,TVV2,ETA_QandETA_V2in one call. η surgery requires the canonicalP = TVP * exp(ETA_P)form and is a hard error naming the parameter on anything else, never a silent wrong edit.ModelText::canonical_hashgives a candidate a stable identity — equal across comment and whitespace changes, different for every semantic one — for use as a fit cache key; a#or//inside a quoted value is content, not a comment, so two candidates differing only in a quoted path ("s3://bucket/a.csv"vs.../b.csv) are two candidates. This is the prerequisite for the model-search tooling in #1175.A cancellable bootstrap (#1161).
BootstrapOptions::canceltakes aCancelFlag; setting it from another thread stops a longferx_tools::bootstraprun at the next replicate boundary and returns the newBootstrapError::Cancelled, so a caller reports an abort as an abort rather than as “every remaining replicate failed”. Replicates the cancel unwound are dropped rather than journaled as failures, so--resumerefits them; everything already finished stays on disk and resumes into exactly the run that was cancelled.W_STEADY_STATE_ABSOLUTE_TIME— a steady-state dose on an[odes]right-hand side that reads an absolute clock is now named (#1139). The run-in standing in for the infinite past expands the dose train on a clock local to each cycle, soTAFD,T/tand the bareTIMEbuilt-in have no periodic steady state for it to converge to:T/TIMEreturn a finite number that matches NONMEM’s own steady-state routine but sits 67 % from the same model’s explicit dose train, andTAFDreturnsNaNwhatever coefficient the term carries.fit()andferx checkboth report it. No prediction, objective or diagnostic value changes: previously the only warnings describing the failure were aW_ODE_SOLVER_DIAGNOSTICSand a failed covariance step, which both point at the integrator, whileferx checksaid nothing at all — those still appear, and this now names the cause alongside them. It is reported per dose that actually reaches the run-in, so anSS=1infusion the run-in skips — one whose length after bioavailability exceeds its ownII, served as a single non-SS infusion — is not swept up.TADis not affected: it is bounded inside one dosing interval, so the run-in reproduces its train and is anchored against NONMEM. ##### FixedA fit whose estimate ran to an internal safety rail no longer reports
converged: true(#1118). ferx caps a few packed coordinates internally (an implicit THETA cap, the OMEGA / SIGMA runaway rails); unlike a THETA bound you declared, one of those cannot be a valid constrained optimum. When a free estimate ends pinned to a rail the fit is now reported as not converged and theparameter_at_runaway_guardwarning is raised toCritical, so a script or agent keying off the boolean stops accepting a point that is by construction not an interior optimum. A collapse hit — a variance falling to its floor at zero — is unchanged: it stays aWarningand leavesconvergedalone, because that is usually an unsupported component to remove rather than a numerical runaway. Each listed hit now says which of the two it is (verdict: runaway/collapsein the warning’sdetails), because the side does not decide it: an OMEGA off-diagonal is bounded symmetrically at ±10, so a correlation driven to the lower rail is a runaway too. A SIGMA at its ceiling additionally suggests rescaling DV or using a proportional / log-transformed error model, since that rail is the one an otherwise sound model can reach on unscaled data.ferx bootstrapreflects the same rule. A replicate that ends at a runaway rail is now a non-converged replicate, so with the defaultskip_minimization_terminatedit is excluded from the confidence intervals and counts against the reportedminimization_successfulfraction. Re-running a bootstrap of an unchanged model can therefore report slightly different CIs than before;--summarizeover a storedraw_results.csvre-applies the criteria without refitting.An
[odes]right-hand side that readsTAD/TAFDno longer destroys the objective on an SDE ([diffusion]) model (#1131). The extended-Kalman-filter path handed the compiled RHS a bare PK parameter array, two slots shorter than the one the ODE predictors build, so both model-time anchors read as missing and the RHS injectedNaN. The state was then clamped back to a plausible-looking number while the observation variance kept theNaN, soipredlooked right and the fit silently reported the diverged-subject sentinel (OFV≈2e20) instead of a real objective. The EKF now carries the same extended array and re-anchorsTADper dose segment by the same rule as the two ODE predictors, so an SDE fit of a time-varying RHS agrees with its zero-diffusion ODE twin. A model whose RHS reads neither builtin is bit-identical to before.A model with no random effects no longer panics when its objective is non-finite (#1259). The covariance step’s non-finite-objective diagnostic asked for the eigenvalues of the 0×0 OMEGA such a model has, which
nalgebrarejects. It now reports the non-finite objective as the failure reason, which is what the diagnostic exists to say.An infusion under
F ≠ 1no longer delivers zero drug on an SDE ([diffusion]) model (#1263). Bioavailability reshapes aRATE-defined infusion’s duration, not its rate, and the EKF path placed the window’s segment boundary at the unscaled end. The infusion window then failed its own membership test in every segment, so the dose delivered no mass at all and every prediction read zero. The boundary is nowF-scaled, matching the ODE path and NONMEM (anchored againstnonmem_anchor/oral_central_inf_advan2_f06).An SDE model whose records start after
t = 0no longer inflates its observation variance (#1263). The EKF began integrating at a hard-codedt = 0rather than at the subject’s first record, so the covariance accumulated process noise across a segment that does not exist — for a first dose att = 24, thirteen times the correct value at the first observation. Aninit(state) = …starting amount was decayed across the same phantom segment.Two observation records at the same time no longer read as zero on an SDE model (#1263). Inside a dose segment the EKF kept one record per observation time, so a second record at that instant kept its initialised
0.0prediction and variance and fed a plausible zero into the likelihood. All records at one instant now share a single filter update, as they already did at a dose boundary.Dosing features the EKF/SDE path does not implement now warn instead of returning a plausible wrong answer (#1263, #1260). An
SS=1record is applied as a single dose rather than equilibrated (W_SDE_STEADY_STATE), and an absorptionlagtime/ALAGnis ignored (W_SDE_LAGTIME) — joining the existingW_SDE_RESET. Neither gap is visible inIPRED, and the steady-state one is large: a 1-cpt model with oneSS=1, II=12record predicts90.48where the equivalent explicit dose train predicts200.27. Expand the steady state into explicit records, or fit without[diffusion].
0.3.1 - 2026-09-02
Added
- A progress bar for
ferx bootstrap. A 200-sample bootstrap is minutes to hours of fitting behind one command, with nothing on the terminal to say whether it was working or wedged. The run now reports each fit as it completes: the base model gets a spinner, the replicates a bar with an ETA,--dofvits own bar for its second pass, and a--resumerun counts only what is left to fit. The bar is drawn only when stderr is a terminal;--no-progresssuppresses it.ferx_tools::bootstrap::run_bootstrap_with_progressis the same run with aBootstrapEventsink, which is what ferx-r renders onto aclibar;run_bootstrapis unchanged. ferx gam— GAM covariate pre-screening from the command line (#1114). Screens every declared covariate against every ETA with independent GAM regressions and prints a table ranked by delta-AIC (AIC_null − AIC_best), the Rust equivalent of Xpose4’sxpose.gam(). Run it on a model and dataset, on an existing fit with--from-fit <run.fitrx>, or without estimating at all with--no-fit(the NONMEMMAXEVAL=0equivalent). Candidate forms are tuned with--spline-df N(repeatable) and--no-linear, the shrinkage warning threshold with--shrink FRAC. Results are written to{model}-gam.csvby default;--csv PATHredirects the file and--no-csvsuppresses it. The same screen runs as part of an ordinary fit with the--gamflag. See GAM covariate screening.
Changed
- The published tarball no longer ships repo infrastructure (#1170).
tests/,nonmem_anchor/,docs/,plans/,tools/, the workflow and hook directories and the repo-process markdown are excluded from the crate: 1289 files / 5.47 MB compressed becomes 366 files / 3.22 MB. None of it is needed to build or document the crate. Running the test suite needs a git checkout, as it needed the NONMEM anchors anyway. No source or API change. - The three published crates now carry
keywordsandcategories(#1170). Registry metadata only – no code change. It is what covers discoverability on crates.io, which is why claiming theferx-nlmename was judged unnecessary in #1170. FitOptions::threadsnow also caps a multi-start fit (#1115). A pinned positivethreadswas honored only whenn_starts <= 1; with several starts the fan-out ran on the full-width shared pool regardless, so a tool that pinned one thread per fit from aPoolPlanand asked for multiple starts got its replicate-level pool and a full-width pool underneath every replicate. The pin is now an upper bound on the wholefit()call. A multi-start fit that pinsthreadsbelow the core count is correspondingly slower than before, and unpinned multi-start fits are unchanged.- The four documentation sections the callout blind spot had been hiding are split into addressable subsections (#1190). ODE models’ Which regime am I in? gains Where the step counts show up, Bounding what a stalled segment costs, Measured: an accuracy-limited fit and Every feature works with every method; the analytic transit closed form gains Scope (first version) and The flip-flop regime; VI’s How
σis updated gains Why the default is 32 draws and If a VI fit lands short; adaptive dosing’s Keys gains Writing anobserveexpression. Nothing was deleted and every existing anchor still resolves — the R1 baseline is one entry shorter than before this PR, not longer. tools/render-docs.shrenders the docs site; prefer it to a barequarto render docs(#1190). Quarto’s project input discovery skips any path containing a hidden (dot-prefixed) directory component, so from a worktree under.claude/worktrees/<name>/a plainquarto render docsdiscovers zero inputs: it writesrobots.txtandsitemap.xml, renders no page, warns about nothing and exits 0. The script stagesdocs/outside the dot directory when it has to, and fails loudly on a zero-page render.- The repo is now a cargo workspace, and the
ferxbinary moved into aferx-clipackage (#1114). The installed binary is unchanged — stillferx, same arguments, same output — but building it from a source checkout now needs the workspace or the package named:cargo build --release --workspace(or-p ferx-cli), andcargo run --release -p ferx-cli -- model.ferx --data data.csv. A barecargo build --releaseat the root now builds theferx-corelibrary only. Feature flags belong toferx-core, so workspace-wide commands write them package-qualified (--features ferx-core/ci). Consumers of theferx-corecrate — including the ferx-r wrapper, which patches the repo root — are unaffected: the root package is stillferx-core. - A covariate column with no value for a subject now reads as
NaN, not as a dropped key (#1111). Previously the key was simply absent from that subject’s covariate map, and every evaluation site resolves an absent covariate to0.0— so a subject whoseWTcolumn was.on every row was fitted atWT = 0, indistinguishable from a genuine zero, andpresent(WT)reported it as present. The reader now storesNaNand warns, naming the covariate and the affected subjects, so the gap is either guarded withpresent(...), imputed, or fails loudly. - The five largest documentation sections are split into addressable subsections (#1162).
[fit_options]’s 36 shared keys are grouped into seven tables (run control, outer optimizer, inner loop, covariance, ODE tolerances, ODE stepper, data handling); FOCE’s gradient-route section leads with the decision and moves the scope history into named sections plus an FD fallback table; SAEM’s theta/sigma M-step,ode_method = auto, and the check-report code table are likewise subdivided. The three heaviest[fit_options]key descriptions are cut to what choosing the key needs, with the detail deep-linked into the pages that already carry it —ODE stepper selection909 → 367 words,Inner loop (EBE)769 → 365,VI-Specific Options747 → 378. Every previous anchor still resolves, eight already-broken in-page links are repaired, and the site’stoc-depthis raised to 4 so the new subsections appear in the on-page table of contents. - The docs content column is wider, and wide tables no longer run under the “On this page” TOC (#1162).
grid.body-widthgoes from Quarto’s 800px default to 1000px (a 749px → 895px content column at 1512px wide), and the desktop table rule now actually contains an oversized table: it setdisplay: tablewithoverflow-x: auto, which is inert, so a table wider than the column drew over the margin TOC instead of scrolling inside itself.
Added
ferx_tools::gam::gam_screen(): GAM-based covariate pre-screening (#1114). For each ETA × covariate pair, fitsη_i ~ f(cov_i)(linear, natural cubic spline, or one-hot categorical) and ranks covariates by AIC improvement over the null model. High-ΔAIC covariates are then prioritised in an SCM. This is the Rust equivalent of Xpose4’sxpose.gam(). Requiresferx-tools. See GAM covariate screening.A covariate that cannot be screened is now reported in
GamResult::warningsrather than dropped in silence — being skipped and being screened-but-unimportant are indistinguishable in a ranking. Skips cover: a constant covariate, a single-level categorical, a categorical or spline form spending more than half the subjects as parameters, a singular design, a column whose length does not match the subject count, and fewer than three usable subjects. An ETA whose EBEs are constant, or not all finite, is refused outright. Shrinkage above 30% warns, and so does an ETA whose shrinkage the fit did not report at all.gam_screen_raw()panics on a length mismatch instead of truncating to the shortest input.PoolPlanandFitOptions::quiet(): the two knobs a tool needs to run many fits (#1115).PoolPlan::from_budget(total_threads, n_units)splits a thread budget between the replicate level andfit()’s own per-subject level (outer level first, so a 200-replicate bootstrap on 8 threads is 8 single-threaded fits at a time),apply_to()pins the inner fit, andinstall()builds the outer Rayon pool with ferx’s 32 MiB worker stack (FIT_RAYON_STACK_SIZE) — a pool built by hand inherits the platform-default 2 MiB stack and overflows on wide ODE+IOV analytic-gradient models.FitOptions::default().quiet()turns off the per-iteration console output; every non-fatal message stays reachable onFitResult::warnings, the optimizer-trace filename now included (it embeds a pid and a timestamp, so a quiet caller could not otherwise learn where the trace went).A
present(COV)condition for covariates that may be missing (#1111). A missing covariate value isNaN, and division by it underflows to0.0here rather than erroring, so an unguarded(CRCL/100)^THETAsilently zeroes the parameter on a row with noCRCL.if (present(CRCL)) ... else 1.0says the guard plainly; the equivalentCRCL == CRCLidiom still works. Usable anywhere a condition is (!present(X),present(X) && X > 100), and it is what[covariate_model]wraps every generated factor in.A
[covariate_model]block: covariate–parameter relationships declared as data (#1111).CL ~ WT power(center = median)desugars into exactly the classical[individual_parameters]expression — with the theta declaration, the centering constant and a missing-value guard filled in — so the OFV and estimates are identical to writing it by hand. All five PsNscmstates are expressible (none,linear,hockey,exponential,power, pluscategoricaland anexpr("…")escape hatch) with PsN’s default inits and bounds;center/breakpoint/refaccept a literal or a data-derived statistic (median,mean,min,max,mode), and[covariates]gainscategorical(levels = [0, 1])/levels = auto. The relations are echoed onFitResult.covariate_relationsand in the fit YAML — each θ naming the categoricallevelit contrasts, eachexpr(…)relation carrying the expression that ran, and a relation with no θ emittingthetas: []rather than a bare key. Statistics summarise every event record (observations, doses,EVID=2markers,EVID=3/4resets), so the default bounds cover the range the fit evaluates over. Centres that would break their own form are refused up front: outside the observed range for the linear family (whose default bounds would come back reversed), non-positive forpower, zero forlinear_relative; so is a categorical value the block never declared, which would otherwise be modelled silently as the reference level (E_COV_LEVEL_UNKNOWN).ferx checkprints the desugared block. Each relation owns its own θ (as inscm); a θ shared across relations is still written classically. Relations are line-oriented and independent, so a covariate search rewrites this one block instead of doing surgery on an expression. Seedocs/model-file/covariate-model.qmd.A CI-enforced public-API baseline for
ferx-core(#1114).api/ferx-core-public-api.txtis a committed snapshot of the crate’s public surface; a CI job regenerates and diffs it, so any widening of the API fails until the baseline is updated in the same PR. Regenerate withtools/update-public-api.sh. This doubles as semver protection for the crates.io release and for ferx-r.#[doc(hidden)] pubis banned, becausecargo public-apiomits such items and the attribute would otherwise be a silent bypass of the gate.A
ferx-toolscrate (#1114) — the home of multi-fit tooling (bootstrap, stepwise covariate modelling, model search, cross-validation). It can only reachferx-corethrough the same public API the R wrapper uses.ferx bootstrap— the non-parametric case bootstrap (#1140). Resamples subjects with replacement, refits the model to each replicate, and reports bias, standard errors and confidence intervals from the spread of the estimates, atPsN::bootstrapfeature parity:--samples,--seed,--threads,--sample-size(including PsN’s per-stratum"1001=>12,1002=>24"form),--stratify-on,--update-inits,--run-base-model,--keep-covariance, the four--skip-*exclusion filters,--dofv, and--summarizeto recompute results under different filters without refitting. Writes PsN-named CSV artefacts (raw_results.csv,bootstrap_results.csv,included_individuals1.csv,sample_keys1.csv, …). Unlike PsN, the drawn datasets depend only on the seed and the design — never on the thread count, the completion order, or whether the base model was fitted first. Bootstrapped parameters are every estimated theta, Omega (free lower triangle), sigma, and IOV Omega, so akappamodel gets bootstrap SEs and CIs for its inter-occasion variance and--update-inits/--dofvcarry it too. A model that readsIDas a covariate is refused rather than silently mis-fitted, and so is a[mixture]model — its classes are identified only up to relabelling, so averaging across replicates would mix them; use SIR for uncertainty on a mixture model instead (#1145). Seedocs/tools/bootstrap.qmd.ferx bootstrap --resume— pick an interrupted run back up (#1143). The per-replicate files (raw_results.csv,included_individuals1.csv,included_keys1.csv,sample_keys1.csv) are now written as each replicate finishes rather than once at the end, so a run killed part-way leaves everything it had already completed.--resumerefits only the samples the directory does not hold, and reuses the base fit rather than repeating it. Because each draw comes from the seed and the replicate’s own index, a resumed run reproduces the uninterrupted one exactly — not merely statistically, which is what PsN’s shared, order-dependent RNG can offer. A replicate whose fit errored is carried forward by default, as in PsN;--retry-failedrefits those instead, for a failure that was a transient resource problem. Every run now writesbootstrap_run.jsonrecording its seed, options — including what the replicates were started from, so a resume cannot mix replicates begun at the base fit with replicates begun at the model file’s estimates — and the hashes of the model and data files; a--resumewhose inputs or options disagree is refused, naming the field. A trailing row cut off by a hard kill is dropped and that sample refitted.raw_results.csvnow carries full round-trip precision rather than ten fixed decimals, because a resumed run’s replicates start from the base-fit estimates read back out of it; the statistics files are unchanged. Seedocs/tools/bootstrap.qmd.prepare_run/prepare_run_with_inits(#1140) — public “load a model and its dataset, but do not fit” entry points returning aPreparedRun.run_model_with_datais now implemented on top of them, so a tool and the CLI cannot diverge in how a model is loaded.
Fixed
A joint PK-TTE model with a time-dependent hazard no longer integrates twice per subject (#1166). The
[event_model] hazard = …expression is appended to the ODE system asd/dt(__chz_<cmt>), and a Weibull or Gompertz baseline hazard readsTIMEby definition — which made ferx treat the whole model as having non-autonomous PK dynamics, declining the single shared solve (#570) and leaving the dense driver, for a property the PK block does not have. The “does this model read model time?” question is now asked of the PK equations alone. Measured on a 40-subject fixture, one variable: 2.1× faster on one objective evaluation and 2.6× over eight outer iterations. Results are unaffected beyond solver tolerance — the same fixture’s objective moves 4.4e-3 at ferx’s default tolerances and 7.7e-7 atode_reltol = 1e-9, i.e. the difference decays with the integrator rather than being a fixed offset — so earlier fits do not need re-running. Steady-state gates deliberately keep the wider question, since SS equilibration integrates the augmented system including the hazard state. Validated against a new NONMEM anchor (nonmem_anchor/pktte_tdep.ctl):H(t)matchesA(3)to 3.6e-9 relative and the per-subject objective matches the.phiOBJto 6.0e-8.__chz_*is now rejected as a read (#1166). The cumulative-hazard accumulator the parser appends for a joint PK-TTE hazard is write-only: referencing it from an[odes]equation, anifcondition or the hazard expression, or seeding it withinit(__chz_<cmt>), is a parse error naming the state instead of silently producing a coupled system the shared solve was never designed for. A[scaling]readout may still read it.The packaged crate no longer ships two files the CC BY-NC exclusion was meant to cover (#1170).
exclude’sdata/mbma_naproxen.*glob required a literal.after the stem, sodata/mbma_naproxen_source.pyslipped through, andexamples/was never listed, soexamples/mbma_naproxen.ferxshipped as an example whose data is excluded and so cannot run. Neither file was itself CC BY-NC (both are repo-authored MIT, and the restricted csv was correctly excluded throughout), but the licence note indata/mbma_naproxen.LICENSEdescribed a mechanism the globs did not implement. Both are now excluded.An infusion into a built-in absorption compartment is no longer delivered twice on the dense/states engines (#1187). With a
RATE>0orRATE=-2dose into afirst_order(),transit(),igd()orweibull()kernel (#719 gap 2), the mass was released both through the convolvedR_in_infand again as a plain constant rate injected straight into the compartment — bypassing absorption entirely.predict()was correct throughout; what was wrong is everything routed through the dense engines: sdtabIPREDand compartment states,[derived]grid integrals,simulate()event times, and — as a wrong objective, not just a wrong diagnostic — the joint PK-TTE cumulative hazard and the Markov/CTMM likelihood. Measured against NONMEM 7.6.0: the cumulative hazard at the event time read 31× high (3.796844vs1.2217e-1), and a concentration readout up to 214× during the infusion window, settling near 1.9× afterwards. Models without an infusion into an absorption compartment are unaffected. Models with one should be re-fitted if they carry a joint PK-TTE or a Markov/CTMM endpoint: those objectives were wrong, so the estimates they produced were too. A pure-PK fit keeps its estimates — its objective always ran through the guarded prediction path — but its sdtabIPRED, compartment states and[derived]columns were wrong and should be regenerated.The docs linter no longer reads a Quarto callout title as a section boundary (#1190). Pandoc lifts the heading that opens a
::: {.callout-*}block into the callout header, so the rendered page gets neither a section nor an anchor from it — butdocs-lintcounted all 37 of them across 15 pages as real headings. That chopped each callout’s body into chunks R1 never measured whole (three oversized sections were hidden, and one baselined section was reported at 5,756 characters when it is 7,331 — all four are now split, see below), and registered ids R3 and R4 treated as addressable. A heading later inside a callout is still a real section. Slug generation is fixed alongside it: inside a code span<op>is literal text, not an HTML tag, so## Rules — `when signal <op> <value>`addresses as#rules-when-signal-op-valueand no longer truncates at the first placeholder. Checked against a fullquarto renderof all 86 pages, in both directions — every heading the parser computes is a section on the site, and every section on the site is one the parser computes.A lagged
zero_orderabsorption route is no longer dropped by the ODE engines that serve diagnostics, the joint PK-TTE hazard andsimulate()(#1171).zero_order(dur=D, lag=L)is delivered as a per-segment constant rate, admitted only when the segment sits fully inside the window[t_dose + L, t_dose + L + D], so the integrator has to break its timeline at the window’s onset as well as its end. The shared helper pushed only the end, leaving the start unbracketed on the two builders that did not separately bracket the route onset — the rate was then dropped for the whole window, and a model whose only input is a laggedzero_orderread exactly zero everywhere on those paths. The helper now emits both edges, so the timeline and the containment test can no longer disagree in any builder. Affected: the sdtabIPREDand compartment-state columns (and theIWRES/CWRES/[derived]values computed from them),[derived]grid integrals, the joint PK-TTE cumulative hazard and its contribution to the objective — which collapsed to the drug-free baselineH0·t, removing the entire drug effect — the Markov/CTMM endpoint likelihood, the adaptive-dosing window AUC signal, andsimulate()event times, where a subject who should have had an event was administratively censored. A pure-Gaussian fit’s OFV andpredict()are unaffected: their predictor already carried the onset break, which is why no existing test caught this. Anchored against a new NONMEMADVAN13run (nonmem_anchor/lagged_zo.ctl,D1+ALAG1withRATE=-2), which the fixed paths now match to the$TABLEprint precision. The onset break is also restored for a laggedfirst_order/transit/weibullroute on those engines, whose kernels step at the onset rather than losing the window; those move within solver tolerance rather than from zero, so no fit result changes materially.An
[odes]right-hand side that is nonlinear in the compartment amounts is no longer served by the closed-form fast path (#1149). Linearity was established by evaluating the right-hand side one compartment at a time with every other amount held at zero, so a term that only switches on away from those points was invisible to it: a product of two amounts (- KON*central*periph, the target-binding/TMDD shape) is exactly zero whenever either is zero, and a branch testing an amount (if (central > 10)) was never entered, because the largest amount probed was 2. Such models were admitted to the closed form, which does not evaluate the right-hand side at all, so the term was dropped from the predictions — the same silent failure as #1124, with no time variable involved. Identification now also checks the right-hand side against the recovered linear system away from the axes, across six decades of amount. Models that are linear in the amounts are unaffected and keep the fast path, including those whose coefficients are arbitrary functions ofθ,η, and covariates.An
EVID=2record no longer aborts a fit on a parameter-static subject (#1124 review). Any subject routed to the event-driven walker while its PK parameters are constant — anEVID=3/4reset, or (since the change above) an[odes]right-hand side that reads model time — had its per-event parameter snapshots built with theEVID=2rows omitted, and the walker asserts one snapshot per record. The run failed on an assertion rather than returning a diagnostic, in release builds as well as debug (the assertion isassert_eq!, notdebug_assert_eq!; it unwinds, so from R it surfaces as an opaque error rather than a message naming the subject). Reaching it needed only a dataset carryingEVID=2rows alongside a varying column the model does not reference, because the irrelevant-covariate pruning clears those rows’ covariate snapshots while keeping their times. Both the ODE and the analytical event-driven engines were affected.A pre-arrival observation on a model-time-reading
[odes]RHS no longer putsNaNinto the FOCEI objective (#1124 review). A subject observed before its first dose — a baseline sample — took the static superposition walk for its analytic sensitivities, and that walk resolvesTAD’s anchor by a fold over doses at or before the segment start, leaving it at-infand evaluating the right-hand side withTAD = NaN. The provider still reported success, so theNaNgradient reached the optimiser instead of falling back to finite differences. Such subjects now take the event-driven walk, which seeds the anchor at the first arrival. Affected fits change their reportedgradient_methodand objective.Steady-state (
SS=1) dosing on a model-time-reading[odes]RHS now takes finite differences on both loops (#1124). The three gates that decline the analytic jet for this combination asked a predicate that could not see the bareTIMEspelling, so aTIME-reading right-hand side reached the analytic steady-state equilibration while the identical model written withTcorrectly declined. Affected fits report a differentgradient_methodand a correspondingly different objective. This changes only which gradient is used; the steady-state value for such a model is wrong on every engine and is tracked separately in #1139.An
[odes]RHS that readsTADis no longer silently dropped from the predictions (#1124). A model whose right-hand side read the time-after-dose built-in — for example- CL/V*central*(1 + 0.3*TAD)on a mixedfirst_order+zero_orderabsorption model — was served by the closed-form modified-release fast path, which never evaluates that right-hand side. TheTADterm was absent from the predictions, not approximated: output was bit-identical to the same model with the term deleted, reaching 8× the correct concentration by 12 h, with no error, no warning, andconverged: true. Because the value is wrong, the error reachedIPRED, the OFV, residuals, and every downstream diagnostic. Such models now integrate on the event-driven ODE path, which evaluates the right-hand side as written. The same routing coversTAFD,T/t, andTIME, including when read only from inside anifcondition (measured 40% error, all four spellings) — the closed form’s time-invariance probe samples two times and could not see any of them. Models that do not read model time are unaffected and keep the fast path.An
[odes]RHS that readsTADtogether with a lagtime no longer returnsNaNpredictions (#1110). The dense predictor anchoredTADon doses that had already arrived, and under a lagtime none has at the first segment’s start, so every prediction came backNaN. These models now take the event-driven predictor, whose timeline starts at the lagged arrival. (#1073, released alongside this, independently gives the dense predictor a pre-arrival anchor, so the two engines now agree to 6e-12 on such a model rather than one of them being unusable.) One part of #1110 stays open and is not fixed here: the variance path used by[diffusion]/ EKF models still reaches the dense predictor with empty parameter slices, returningp_obs = NaNandipred = 0for anyTAD-reading right-hand side, with or without a lagtime (#1131).41 dead links and 11 duplicate anchors in the documentation (#1163). Every internal documentation link now resolves, anchor included: numbered headings (
## 3. Communicationis addressed as#communication, not#3-communication), anchors hand-written with a double hyphen where the generated id has one, and links pointing at sections that never existed. Repeated headings on a page — four sections called “Syntax” on the absorption page, addressed as#syntax,#syntax-1,#syntax-2,#syntax-3— were given distinct titles, so some anchors on the published site changed. A newdocs-lintcheck keeps all four properties true on every PR; see the Docs linter page.An EVID=3/4 reset now re-seeds
[odes] init(...)from the reset row’s own covariates (#1133). A reset row is a NONMEM data record —$PKruns at it — but ferx restarted the episode using the previous record’s covariate snapshot, so a covariate-driveninit(state) = <expr>began the new episode on a stale value. Withinit(central) = 10*WTandWTstepping 70 → 140 at the reset, every post-reset prediction was a factor of two out against NONMEM 7.6.0. Fixed in all four affected engines (the dense ODE predictor, the analytic-sensitivity twin that supplies the FOCE/FOCEI gradient, the adaptive-dosing driver, and its frozen-schedule replay verifier), so fitted parameters,predict(), andsimulate_adaptive()all move. Datasets with time-constant covariates, and models without aninit(...)seed, are unaffected. The same rule applies to the reset row’s occasion: with aniov_column,$PKat the reset runs under that row’s ownOCC, so a reset that opens a new occasion re-seeds under the new occasion’s κ rather than the previous record’s — measured against NONMEM innonmem_anchor/reset_init_snapshot_J.ctl(42.0 under the reset row’s occasion against 14.0 under the preceding record’s). Note the remaining gaps: an analytical model using[initial_conditions]still drops its baseline at the first reset instead of re-depositing it (#1135), a reset row whose occasion carries no kappa group falls back to kappa = 0 inconsistently across engines (#1153), and an adaptive controller’s decision-time covariate LOCF still skips reset rows (#1148).A steady-state dose with a lagtime is now seeded at the dose record, not equilibrated at the arrival (#1121). Under time-varying covariates an
SS=1dose carrying anALAGhad its periodic trough computed at the lagged arrival, entirely under the dose row’s covariate values. NONMEM loads the trough at the dose record (phaseII − ALAG) and advances to the arrival under the record governing that interval, so ferx applied too little elimination across the pre-arrival window and ran high for the rest of the cycle. On an eight-subject anchor (nonmem_anchor/dose_form_lag_ss) this was worth 7.67 objective units; predictions are now within 1e-4 of NONMEM 7.6.0 point by point. Flat-covariate fits are unaffected — the two constructions agree exactly when nothing changes inside the window, which is why this went unnoticed. The same fix gives the IOV predictor a pre-arrival state, where it previously read zero for any observation between anSSdose’s record and its lagged arrival.A steady-state infusion whose previous cycle is still running at the dose record keeps delivering (#1121). When
ALAG > II − T_infthe pulse before the record is recent enough that its infusion has not finished, so the rate flows on past the record and stops atrecord + (T_inf − phase). That window belongs to no dose row, and every engine dropped it — reading about 4 % low across the whole pre-arrival window and, since the two production engines and the dense predictor disagreed about it, giving the same model different answers depending only on whether the subject happened to carry a time-varying covariate. Now carried explicitly and matched against NONMEM 7.6.0 (nonmem_anchor/ss_lag_infusion).A lagtime of a full dosing interval or longer on a steady-state dose is now supported (#1121).
ALAG >= IIhas no phaseII − ALAG; ferx now clamps it to zero, matching NONMEM — the pulse lands on the dose record, so the record carries the steady-state peak and decays from there. Previously the event-driven walks returned exactly0for the whole pre-arrival window (under a proportional error model, a very loud failure), while the static predictors wrapped the phase into[0, II)and then re-equilibrated at an arrival that is no longer the periodic trough, running about 4 % high after it. Clamping is also continuous inALAG, so an estimated lagtime no longer steps the objective as the optimiser walks it across the interval. One corner is a deliberate divergence: for anALAG >= IIinfusion NONMEM deliversT_inf + (ALAG − II)hours of drug rather than the dose’s ownT_inf, which is not mass-balanced; ferx does not reproduce it. Seedocs/model-file/lagtime.qmd.VI no longer freezes from ordinary starting values (#1097). Adam’s gradients are now clipped to a global L2 norm, controlled by the new
vi_grad_clipfit option (default1e4;0restores the old behaviour). Under a proportional error model a prediction near zero makes the ELBO gradient enormous, and Adam’s second moment — an average of squares with a ~1000-iteration memory — absorbed it and left every subsequent step numerically zero for tens of thousands of iterations. The frozen trace then read as settled, so the run stopped early and reportedσwelded to its internalexp(5)guard. On warfarin from theferx-testdatainitial estimates this cost ~1594 objective units against FOCEI; the same fit now reproduces the NONMEM FOCEI estimates (TVCL 0.13269,TVV 7.7379,σ 0.0116) with no warm start. Fits that already converged are unaffected — clipping is asymptotically a no-op because Adam is scale-invariant in steady state.VI’s bad-basin detector now reports
converged: false(#1098). An implausibly loose final ELBO already identified a fit trapped far from a usable variational approximation, but the result still exposedconverged: trueto programmatic consumers. Such fits are now demoted, omit the misleading “increasevi_iters” warning, and carry the critical structured warning codevi_bad_basin.The analytic gradient of an
[odes]RHS that readsTADunder an estimated lagtime is now correct (#1070).TADwas handed to the ODE right-hand side as a plain constant, but its anchor is the dose’s lagged arrival (t_dose + ALAG), so the∂TAD/∂ALAG = −1term was silently missing from the analytic η/θ gradient — everywhere the trajectory is integrated, not just at a dose event. Predictions were unaffected, so nothing failed visibly; but FOCEI builds itshmatrix from that same gradient, so the error reached the reported OFV. Measured against finite differences of the production predictor, the worst axis was 66% wrong (∂f/∂η_ALAG) and 68% wrong (∂f/∂θ_ALAG); against NONMEM 7.6.0 the objective was 0.176 off on a single-dose anchor and 0.107 on a two-dose one.TADis now threaded through the sensitivity walks as a dual, so those become 0.0004 and 0.032 — the same values the finite- difference route gives (nonmem_anchor/tad_lag_*). Affected models keep the fast analytic route on both loops and under IOV, including when the lagtime carries inter-occasion variability. Predictions are bit-identical to before. Models readingTADwithout a lagtime, or readingTAFDwith one, were always exact and are untouched.TADcombined with a steady-state dose still routes to finite differences, under the separate non-autonomous-RHS rule.Two consequences worth knowing before you re-run an affected model. First,
gradient_method = autoresolves this model class to L-BFGS where it previously chose BOBYQA, because the choice keys on whether an analytic outer gradient is available — so the optimizer changes as well as the gradient. Setoptimizer = bobyqato keep the previous behaviour. Second, the second-order block∂²f/∂η_ALAG²is improved but not exact (6.4e-1 wrong before, 2.2e-1 after); it does not affect the objective value, but it does feed the analytic outer gradient and the covariance step, so standard errors and the optimizer’s search direction on these models carry that residual until #1075 lands. TheTADvalue in the window before the first dose arrives is differentiated consistently with whatever convention the predictor uses, but that convention itself is still open (#1110) and is not anchored against NONMEM — NONMEM has noTADbuilt-in in$DES, so there is nothing external to anchor it to.A lagged dose’s covariate snapshot no longer stretches to its arrival (#1073). With a lagtime, ferx broke the integration timeline only at the arrival
t + ALAG, never at the dose row’s own time, so the dose row’s covariate / IOV snapshot governed everything up to the arrival. NONMEM evaluates$PKat every data record and then advances to that record, so the interval containing a lagged arrival belongs to the record that terminates it. On the committed multi-dose anchor this was worth 14.89 OFV (ferx−474.660106vs NONMEM−459.772592); it is now~1e-5. Applying the same rule to the other non-record boundaries — an infusion end, a zero-order absorption cutoff, a per-route onset falling strictly between two records — fixed a second divergence of the same family in the ODE engine, measured at 4.2 % on the predictions after the boundary and 23.5 OFV on a dedicated NONMEM probe. The closed-form engine already had that one right, so the two production engines now agree where they used to differ. This moves converged fits for any model combining a lagtime with time-varying covariates or IOV, and for any ODE model whose infusion or zero-order window ends between records under a changing covariate; re-run affected analyses rather than comparing estimates across the change. Fixed in all four engines — both production predictors and both analytic-sensitivity twins — so FOCE/FOCEI, SAEM, VI and the HMC sampler move together, and in the reactive adaptive-dosing walk (#391), where a base infusion whose window ended between two records under a changing covariate was 11 % off the static engine.TADis finite before a lagged first dose arrives (#1073). An[odes]RHS reading theTADbuiltin previously sawNaNfor any step between the first dose row and that dose landing, which multiplies into the compartment state and turns an otherwise finite fit into the1e20objective sentinel.TADthere is now measured from the subject’s first arrival — negative in that window, reaching0when the dose lands — and depends only on the dosing, so adding an observation inside the window cannot move a later prediction. Both ODE predictors agree, so two subjects of the same model no longer behave differently depending on whether they carry anEVID=3/4row. A subject with no doses at all still readsNaN. WhatTADshould mean before a dose has arrived remains open (#1110).vi_mc_samplesnow defaults to 32, not 8 (#1017). This draw count sets the noise floor VI’s settling test measures against, so it decides where a fit stops — and therefore what is certified — rather than merely how noisy the trace is. At 8, ondata/warfarin.csv(~1% proportional residual),σlanded 31% above the0.010565both AGQ (n_agq = 9) and FOCEI give, with the OFV 9.6 units short of AGQ’s−285.977, and it was not reliably flagged: the drift check only demotes a run still descending when it stops, and at 8 draws the run can instead settle at a biased fixed point where the trace tail has genuinely plateaued andelbo_tightness_ratioreads a healthy0.989—vi_seed = 99with otherwise default options returnedconverged: trueatσ = 0.013761. At 32,σis +10% with the OFV within 1.4 units and no seed tried certified a materially wrong fit (a bad-basin seed was correctly reportedconverged: false). Cost is sublinear, because a lower noise floor also settles sooner: 4× the draws cost ~2.3× the wall time (3.4 s → 7.7 s on this fit). Setvi_mc_samples = 8explicitly to restore the old behaviour, and checkσagainst alaplace/foceifit if you do.methods = [vi, laplace]withn_agq > 1is no longer rejected (#1017).n_agqis a chain-wide option, so the documented VI readout —methods = [vi, laplace],agq_eval_only = true, which turns VI’s ELBO lower bound into a real−2 log L— carries it legitimately: the grid belongs to the Laplace stage and VI ignores it. The check rejected it for the whole chain, making the recommended path impossible to ask for. It now fires only when no stage consumes the option (a VI-only chain).A VI run that stops while its objective is still falling no longer reports
converged: true(#1017). The settling test asks whether the ELBO’s remaining drift is distinguishable from Monte-Carlo noise; at a lowvi_mc_samplesthat can become true while the objective is still descending, so the fit stopped short and certified itself. A sign test over the trace tail now separates a real trend from noise — below the amplitude of any single window comparison, which is what makes such drift invisible to the settling test — and a run stopped that way reportsconverged: falsewith a warning namingvi_mc_samples.The sign test alone did not make the old
vi_mc_samples = 8safe on a small residual error, which is why the default moved to 32 (see above). Ondata/warfarin.csv(~1% proportional residual) the default draw count landsσ ≈ 0.0138against0.010565from both AGQ (n_agq = 9) and FOCEI — 31% high, OFV 9.4–9.6 units short of AGQ’s−285.977, and every variational covariance ~1.7× too wide. The sign test catches this only in the regime where the run is still descending at thevi_itersceiling. It can also settle at a biased fixed point of the sampled objective, where the trace tail has genuinely plateaued and no trend test can see anything: withvi_seed = 99and otherwise default options the fit stops at 17 125 iterations reportingconverged: trueatσ = 0.013761. Raisevi_mc_samples(32 → 1.4 OFV, 128 → 0.37) or checkσagainst alaplace/foceifit when the residual error is under ~3%. See the VI page.VI’s early stopping now requires the per-subject posteriors to have settled, not just the population parameters (#1017). Either convergence criterion is sufficient, and the parameter-stability one measured only the packed
(θ, Ω, σ)vector. A chain in which the population coordinates settle first — or in which most are FIXed — could therefore stop and return a still-moving variational posterior as converged, which is the object a VI fit exists to produce. Both halves are now judged on the same window and tolerance.block_sigmacorrelated residual errors work withmethod = vi(#1017). The option docs said this configuration falls back to Adam onσ, and the code implements that, but the check-time allow-list omitted VI, so everyblock_sigma+ VI fit was refused before the fallback could run. VI’s data term routes a denseRthrough the same full-FD path FOCE and SAEM use, so it is supported; only the closed-formσmaximizer is not, and that declines with a recorded reason as documented.A VI block overtaken by a later estimating stage now says so (#1017). On
methods = [vi, focei]the reported θ/Ω/σ and subject diagnostics come from FOCEI whilevi.eta_means,eta_covs, both ELBO halves andelbo_tightness_ratiostill describe VI’s parameter point. The block is still reported — a trailingagq_eval_only/imp_eval_onlyreadout does not move the estimates, and the variational covariance is the only per-subject one in the result — but it now carriessuperseded_by(in the API and the fit YAML) naming the stage that moved on, plus a warning.Subject IDs in the VI posterior YAML are quoted (#1017).
id: 001parsed back as the integer1, and an ID containing:or#could change the document’s shape or truncate the value — defeating the point of keying the posterior by the original subject ID.
Added
Correlated-residual fits now retain their fixed
block_sigmacorrelations (#1100).FitResult.residual_correlations, JSON,.fitrx, and fit YAML now carry the declared correlations, so consumers can reconstruct the sigma-scale residual covariance without re-reading the model source; diagonal-sigma output remains unchanged. The fit YAML’sblock_sigmasection reportscorrelation_fixed(always true) separately fromcovariance_fixed, which is true only when both sigma SDs were declaredFIX.Theta level blocks — hundreds of fixed effects from one declaration (#1064).
theta PLACEBO[800](0.0, -10.0, 10.0)declares 800 thetas sharing one init/bounds triple, read back by a gather —PL = PLACEBO[PLA_IDX], wherePLA_IDXis a 1-based data column. The data-driven formtheta PLACEBO[STUDY, TIME](0.0, -10.0, 10.0)— data columns in the brackets rather than a count — instead declares one theta per observed combination of the named columns, discovered when the data is bound, and reports them asPLACEBO[STUDY=7,TIME=4]. This is what makes an unstructured placebo effect writable in a model-based meta-analysis — and fittable: the whole block occupies a single parameter slot, so it is not bounded by the fixed individual-parameter layout the way 800 separate parameters would be. A level block is rank-deficient against a fixed intercept and against a random effect at the same or a coarser grouping, so a sum-to-zero convention is always applied and its grouping is chosen from both the model and the data;contrast = sum_to_zero | sum_to_zero_within | ref | noneoverrides it, and a choice that leaves a nested group’s mean free is refused. An out-of-range or non-integral gather index is reported before the fit starts, naming the column and value. Seedocs/model-file/parameters.qmd.Three-argument
clamp(x, lo, hi)in the model DSL (#1092). Bounding a readout into an interval no longer has to be written as the nestedmin(max(x, lo), hi), whose argument order flips between the inner and outer call. Available in every expression the DSL parses ([individual_parameters],[scaling],[odes],[derived]); it desugars to the inline conditional, so it differentiates and compiles exactly like the nested form.clampwith any arity other than three is a parse error — a one-argumentclamp(x)would otherwise have been read as the silent identity — and literal bounds given the wrong way round (clamp(x, 0.9, 0.1)) are rejected rather than quietly returning a bound for everyx. One input is deliberately not the nested form:NaNfails both bound tests, soclamppropagates it into the objective function, wheremin(max(x, lo), hi)would have pinned it toloand let a wrong number fit.[simulation]can now state the covariates of the arms it invents (#1083).covariate NAME = <value>— a scalar for every subject, or= [v1, v2, ...]with one value per subject — gives the synthetic subjects a covariate value, which a[simulation]design previously had no way to express at all. This is what makes trial simulation work for a model-based meta-analysis:kappa K ~ γ² weight = NARMandDV ~ additive(S) weight = WPSEboth read a data column, and the arm a simulation is proposing has no row to read it from. A model that references a covariate the design does not supply is now refused by name, with the fix in the message, rather than reported as a column “not found in data” on a path that has no data file. Seedocs/model-file/simulation.qmd.ode_method = autopicks the ODE stepper for you, and is now the default (#978). An a-priori stiffness probe builds the JacobianJ = ∂f/∂uof the[odes]right-hand side at the state each integration segment starts from and reads its fastest mode,max |Re λ(J)|; a system above30per time unit starts on a stiff Rosenbrock method (rodas4, orrodas5patode_reltol ≤ 1e-8), everything else stays on the explicit default. Probing per segment rather than once per model is what makes it work on binding/TMDD models, whose fast eigenvalue is carried by a term likeKON · centraland so is identically zero at the declared initial condition — the one state at which such a model looks non-stiff — and it re-decides as the optimizer moves θ. An escalation that returns a non-finite trajectory, or that clamps at the minimum step (the freeze-pad failure that produces finite, silently wrong output), is discarded and re-solved explicitly; if that fallback also fails, the solver reports that both attempts were unusable rather than presenting the retry as a successful repair. The guard applies toautoonly, and a namedode_methodis honoured exactly as before. Measured on ferx-testdata: the stiff cyclophosphamide model converges in 0.7 s at OFV 3241.708 (NONMEM FOCEI: 3241.721) whererk45spends 827 s without finishing one optimizer iteration, and a TMDD fit runs 372.4 s → 2.3 s at a fixed iteration budget; nothing changes where nothing is stiff. Two new counters on the solver-statistics struct record what it did — segments escalated, escalations rejected — and the fit reports them through theode_solverwarning (#1080). The threshold is a rate and therefore carries the model’s time unit (calibrated on the hour-based PK convention), so name a method explicitly on an unusual time scale. See ODE models → Letting ferx pick the stepper.Fits now report what the ODE solver did (#1080). A new
ode_solverwarning summarises one post-fit prediction pass over every subject: steps that clamped at the minimum step size (a stability-limited segment whose un-integrated tail is freeze-padded with the last state), segmentsode_method = autoescalated to a stiff method, and — the actionable one — escalations the guard had to discard and re-solve explicitly, which means the stiffness probe was right that the segment is stiff and wrong about which stiff method could integrate it. Until now no production path reported solver statistics at all, so both the escalation and its rejection were invisible outside the test suite. An escalation that simply worked is reported atInfoseverity; anything that did not integrate cleanly is aWarning. The counters ride along in the warning’sdetailspayload.ode_method = autocan now change stepper inside a segment (#1080). The stiffness probe reads the Jacobian at each segment’s entry state, which is the state a binding model looks least stiff at — both factors of aKON · C · Rterm are zero when a dose lands. A depot-absorption binding model reads|Re λ|max = 10on entry and7.6e3an hour later; integrated explicitly, that segment exhaustsode_max_stepsand comes back with a median relative error of 143 %, and it does so with zero minimum-step clamps and a lower step-rejection rate than the accurate cases, so no step-history counter can see it.autonow re-runs the probe every 25 accepted steps and swaps the stepper in place when the verdict changes, keeping the state integrated so far: 143 % error in 10 000 steps becomes7e-8in 90, matching the method a user would have had to know to name in advance. A mid-segment escalation is guarded exactly like a start-of-segment one (discarded and re-solved explicitly if it comes back non-finite or clamps on the stiff stepper), the event-time path switches on the same rule, and a benign segment stays bit-identical — it is never re-probed at all below 25 accepted steps, and pays about one probe per 25 steps above that. Newode_auto_switch = falserestores one method per segment, chosen at its start; a namedode_methodis pinned as before. See ODE models → When a segment turns stiff halfway through.ode_stiff_abort_afterbounds what a stalled ODE segment costs (#708, #1080). A segment that is stability-limited keeps stepping at the minimum step size until it exhaustsode_max_steps; setting this key gives up after that many clamped steps instead. Off by default and deliberately so — aborting freeze-pads the segment’s remaining output times, and it does so on every likelihood evaluation, which makes the objective discontinuous in θ; a fit whoseode_solverwarning reports aborts is a diagnosis (“these segments are stability-limited”), not an estimate. It is a way to make a grinding fit say so quickly, not a substitute for choosing a stiff method. On the time-to-event path an abort is reported as a failed segment rather than padded. See ODE models → Which regime am I in?.The
init(...)scope rule is now published by the engine (#994).ODE_INIT_SCOPE_BUILTINSandODE_INIT_REJECTED_BUILTINSname the built-ins an[odes] init(...)expression may and may not reference, so a code generator can source the rule from the same binary it will parse with instead of mirroring it by guesswork — the arrangementknown_block_names()(#1040) already provides for block names. The[odes] init(...)diagnostics are rendered from the same lists, so message and guard cannot drift. They are named for the[odes]surface because the two surfaces genuinely differ: outside[odes],MACHEPS/TAFD/TADare ordinary covariates rather than solver-injected built-ins (the same deliberate rule[scaling]follows), so onlyTIMEis rejected in[initial_conditions]. Both surfaces are documented side by side under ODE models → What aninit(...)expression may reference.center/scaleon[covariate_nn]— per-input normalization, so the network sees(x - center) / scale. Both default to the identity, leaving existing models unchanged. Raw covariates are badly scaled for a neural net:WT ≈ 70saturates atanhlayer at initialization, where its derivative is ~0 and the layer is nearly blind to its input. On a two-covariate DCM, unnormalized inputs pushed weights to ~1e11 and left the residual error 15× too high; the same model with standardized inputs kept every weight inside[-1.3, 1.6]. The constants are declared rather than estimated from the data so thatpredict()applies the same transform the fit used — recomputing statistics on new data would silently change the model — and so the model file remains a complete description of the transform, as(WT/70)^0.75already is. The fit output records the transform alongside the weights:FitResult.neural_networks[k]carriesinput_center/input_scale, and theneural_networks:YAML block printscenter:/scale:when the model declares them — the reported weights were fitted against(x - center) / scale, so a consumer that reconstructs the network needs both.initon[covariate_nn]— one starting value per output, on the parameter’s own scale (init = [1.0, 10.0]for a CL of 1 L/h and a V of 10 L). Set this on every DCM. Without it every output-layer bias starts at 0, so asoftplushead starts every PK parameter atsoftplus(0) = 0.693— a 0.69 L volume regardless of what the parameter means — and the fit begins orders of magnitude away from the data. That is not a slow start, it changes the answer: on a 60-subject busulfan-shaped deep compartment model with identical data and seed, the bare head converged to−2 log L2033.6 with the clearance decline understated 43%, and reportedconverged: true; declaringinit = [1.0, 10.0]reached 1061.0 and recovered the decline. The value is realised exactly rather than approximately — the output-layer weight block is zeroed alongside the biases, so the network emits exactlyinitfor every subject at iteration 0 whatever its covariates, and those weights take gradient from the first step. Values must be reachable by the output activation, or the model file is rejected rather than producing aNaNweight.
Performance
- The
ode_method = autoprobe is ~10× cheaper on models that are not stiff (#1080). Before running the eigensolve, the probe now checks Gershgorin’s bound on the Jacobian’s spectrum: a bound below the stiffness threshold proves no eigenvalue reaches it, so the segment can keep the explicit stepper without theO(n³)decomposition. Ordinary absorption/elimination segments — the overwhelming majority of what a fit integrates — exit there. Measured on a 3-state binding model: the probe drops from 0.56 µs to 0.055 µs per segment, takingauto’s overhead over a pinnedrk45from 33 % to 3 % on a bare segment and from 19 % to 1.3 % on a realistic observation grid. No decision changes — the bound only short-circuits the direction it can prove.
Changed
- The ferx website and GitHub landing page are easier to discover and share (#1089). Documentation pages now publish descriptive search and social metadata, canonical URLs, and clearer NLME and population PK/PD summaries while retaining the generated sitemap and crawler instructions.
optimizer = autono longer picks BOBYQA on high-dimensional problems (#1064). BOBYQA interpolates a quadratic over the whole parameter space, so its model grows quadratically in the parameter count; above 64 free coordinatesautonow resolves tonlopt_lbfgs, whose finite-difference cost is linear. An explicitoptimizer = bobyqais still honoured, now with a warning about the size.- The covariance step routes away from
MATRIX=Ron high-dimensional problems (#1064). The defaultcovariance_method = rre-converges every subject’s EBEs at each ofn(n+1)/2stencil points — around 320,000 population objectives at 800 parameters. Above 100 free coordinates a defaulted covariance method now uses the score cross-product (one pass) and says so; settingcovariance_method = rexplicitly still forces it, with a warning naming the cost. - Large theta level blocks are reported compactly (#1064). A block of more than 20 free coefficients leaves the main estimate table for a one-line summary (count, min, median, max) on the console and in the text report. Every independently estimated coefficient is written to the fit YAML under
theta_blocks:; constrained dependent levels are derived values rather than estimates. - ODE models now choose their own stepper by default (#978).
ode_methoddefaults toautoinstead ofrk45, so a model that names no stepper is probed per integration segment and runs a stiff method on the segments that need one. A model that does name a method is unaffected — naming a stepper still pins it exactly, probe and all. Two consequences worth knowing: predictions on a stiff model will change, because they are now produced by a different (and better-conditioned) integrator — on the cyclophosphamide model this is the difference between a fit that converges in 0.7 s and one that spends 827 s without finishing an optimizer iteration; and a model that reports its resolved options will showautowhere it used to showrk45. Most non-stiff models keep the explicit stepper and their existing numbers, paying one Jacobian and one eigensolve per segment — the work of a single Rosenbrock step attempt — for the check. Two kinds do not, because the probe reads how fast a system’s fastest mode is and not how separated its modes are: a model on a minute clock (the threshold is a rate, calibrated for hours) and a model whose modes are all equally fast (a transit chain written out in[odes]with a largektr). Those escalate without being stiff, which costs time rather than accuracy. Pinode_method = rk45to restore the previous behaviour exactly. - The Rosenbrock steppers are promoted from experimental to beta (#978). Making
autothe default puts them on the default path, so leaving them marked experimental would have meant shipping experimental components to every ODE user. The promotion rests on this release’s validation: a NONMEM FOCEI anchor on the cyclophosphamide model (ferx 3241.708 vs NONMEM 3241.721) and prediction-equivalence tests across all five methods. - The naproxen MBMA fixture is re-sourced to its primary publication, and is licensed CC BY-NC 4.0 rather than MIT (#1085).
data/mbma_naproxen.csvwas derived from the supplementary material of Bracis et al. (CPT:PSP 2026;15:e70158), which is CC BY-NC-ND — a licence that forbids distributing an adapted copy at all. The dataset is not theirs: it originates with Boucher & Bennetts, Many Flavors of Model-Based Meta-Analysis: Part II, CPT:PSP 2018;7:288–297 (CC BY-NC, no ND clause), whom that tutorial replicates. The fixture is now derived from the primary source instead, which removes the no-derivatives problem and makes the attribution correct. Everything in the repository stays MIT exceptdata/mbma_naproxen.csv, which carries CC BY-NC 4.0 — so commercial use of that file is not granted. The exception is recorded in the rootLICENSE, indata/mbma_naproxen.LICENSE, and in the data README, andCargo.tomlnow excludes the files — together with the slow test that reads them, which would otherwise panic on a missing fixture — so the crate published to crates.io is uniformly MIT. The data itself is unchanged but for one digit: the tutorial’s precomputedWP / WPSEcolumn stores 7 decimals, and for one of 122 rows that intermediate rounds the sixth decimal down where the primary source’sWPandWPSEround it up. No estimate or OFV moves. - Every line in
[structural_model]is now checked (#811). The block was scanned for the firstpk NAME(...)match with an unanchored pattern and every other line was discarded, sozpk one_cpt_iv(cl=CL, v=V)parsed as a valid one-compartment IV model and a mistyped or stray line vanished without a word. Each line must now be one of the four accepted forms —pk NAME(...),ode(states=[...]),ode_template NAME(...), or an equation line — and anything else, a second disposition line, or a mix of a compartment model with equation lines is a parse error naming the offending line. A model file that relied on the old leniency was already being read as something other than what it said. - A single-endpoint
[error_model]must now name its sigmas in declaration order (#1001). A one-lineDV ~ ...error model consumes its sigmas positionally from the[parameters]declaration order. The names written in the arguments were checked for existence and then discarded, soDV ~ proportional(S_SMALL)boundS_BIGwheneverS_BIGhappened to be declared first — andcombined(A, B)ignored a transposition of its two arguments entirely. The fit converged and every reported SE and diagnostic was internally consistent, so there was nothing to notice; on a 24-observation dataset two sigmas 40× apart moved the objective by 110.7 units (confirmed on both engines against NONMEM 7.6.0,nonmem_anchor/sigma_order_{small,big}.ctl). Mismatches are now rejected at parse time withE_SIGMA_ORDER_MISMATCH, naming the argument, the sigma that actually occupies the slot, and the fix. This generalises a check that previously fired only whenblock_sigmawas present. Per-CMT and covariate-selected error models bind by name and are unaffected, as is a trailing sigma consumed elsewhere (e.g. FREM’sfrem_sigma). Breaking for any model that named its sigmas out of order — such a model was already getting a different fit from the one it appeared to describe. Fix it by reordering thesigmadeclarations (and, for ablock_sigma, permuting its lower triangle to match); reordering the arguments instead also parses but swaps which sigma is the proportional component, which changes the model rather than the spelling. - The dose-attribute double-use error now covers analytical (
pk ...) models (#1004). #993 rejected this on ODE models only, reasoning that an analytical model’s explicitpk(..., f=F)mapping made a second use “stated rather than silent”. It is not: nothing in the model says the value is applied twice, and a[scaling]or[adaptive_dosing] observeexpression that reads a mappedf=/lagtime=parameter applied it once at the dose and once where it was read — on the default engine, with no diagnostic. Measured at exactlyFon the prediction. Now rejected at parse time with the sameE_DOSE_ATTR_DOUBLE_USEcode. The remediation differs from the ODE engine’s: there the name routes the parameter, so renaming fixes it; here the mapping binds it, so the fix is to drop thef=/lagtime=argument (or the read), and the message says so. A parameter merely namedFthat nopk(...)argument maps stays an ordinary parameter — unchanged, as does an[initial_conditions]read: an initial condition is not an absorbed dose, so the engine seeds the amount withF = 1and no lag andinit(depot) = F * 500appliesFonce. Breaking for a model that maps a dose attribute and also reads it, e.g. the apparent-volume idiompk(..., f=F)+obs_scale = V / F; a model usingCL/F,V/Fapparent parameters without mappingf=is unaffected, which is the ordinary NONMEM convention. Note this is again stricter than NONMEM:$PKdefiningF1andS2 = V/F1runs clean underADVAN2and returns predictions scaled by exactlyF1— anchored on NONMEM 7.6.0, two streams differing in one$PKline (nonmem_anchor/analytical_dose_attr_double_use_{A,B}.ctl). method = imp,impmapandbayesare now rejected byferx checkon a model with no random effects (#1007). All three already refusedn_eta = 0at run time, so amethods = [focei, imp]chain ran its whole FOCEI stage before failing andferx checkreported the model as valid. The newE_METHOD_NO_RANDOM_EFFECTSdiagnostic fires up front, anywhere in a method chain, matching thesaemguard added in #1002. The run-time errors stay as the backstop for directfit()callers. One consequence to note when upgrading: a chain withimp_eval_only = trueon a fixed-effects-only model previously returned a fit result with the IMP failure downgraded to a warning, and now returns this error instead.method = gnon a fixed-effects-only model now warns (#1006). Pure Gauss-Newton is start-sensitive atn_eta = 0: with no inner EBE loop to absorb a poorsigmastart, the BHHH step can collapse far from the optimum and return a badly wrong answer whose only signal wasConverged: NO.ferx checknow emitsW_GN_NO_RANDOM_EFFECTSpointing atgn_hybrid/focei, and an unconverged pure-GN run atn_eta = 0adds a matching post-fit warning. A warning rather than an error, sincegndoes reach the optimum from a good start. Both are suppressed when a later stage re-optimises the GN result —gn_hybrid, and equally a hand-writtenmethods = [gn, focei].- An unrecognised
[block]name is now an error (#1040). Blocks were read by name lookup, so a header the parser did not know was never read and never reported: a misspelled[fit_option]leftferx checksayingvalid: truewhile the fit ran with the default method, the default iteration cap and no covariance step — returning without standard errors and no indication why. The same went for[scalings],[outputs],[derived],[covariates]and friends. Block names are now closed-world, like the keys inside a block already were: an unknown header isE_UNKNOWN_BLOCK, listing every offender with its line, the full valid set, and a did-you-mean for a near match. Two neighbouring silent drops go with it — an instance name where none is taken ([fit_options DOSE]) or missing where one is required ([covariate_nn]) isE_BLOCK_INSTANCE_NAME, and a block whose cargo feature this binary lacks ([event_model]without--features survival,[markov_model]without--features markov) isE_BLOCK_FEATURE_DISABLEDinstead of being parsed away, and[initial_values]— ferx’s own former spelling for initial estimates, unread since they moved inline into[parameters]— isE_DEPRECATED_BLOCK, naming the replacement rather than offering a did-you-mean that does not exist. The recognised block names are exported asknown_block_names()so wrappers can read the list from the engine rather than keeping their own copy; it reflects the features the binary was built with, so it never advertises a name the same binary would refuse. - A dose attribute that is also read by the model is now an error (#993).
F,LAGTIME/ALAGand the compartment-indexedF{n}/ALAG{n}/LAGTIME{n}are applied by the engine at the dose event. A model that declares one and also references it in the[odes]RHS, the[scaling]readout, or the[adaptive_dosing] observecontroller signal was applying it twice — silently, and by exactly that factor: an ODE model reading its ownFproduced every prediction scaled byF, and renaming the parameter changed the fit by that amount with no diagnostic either way. This is now rejected at parse time withE_DOSE_ATTR_DOUBLE_USE, naming both readings and the fix.D{n}/R{n}carry the same reservation but are consulted only for a codedRATE=-2/-1dose, so that collision is reported against the dataset (same code) and a model whose data never codesRATEis untouched. Reads from[derived]/[output]are post-solve reporting and remain silent, and so is an initial condition on either engine ([odes] init(state) = …,[initial_conditions]): the engine seeds the state with the raw expression value and consults dose attributes only at dose events, soinit(central) = F * 100— the bioavailable residue of a pre-study dose — appliesFexactly once and is a legitimate model (#1046). NONMEM agrees:A_0(1) = F1*100withF1 = 0.5seeds 50, not 25, andA_0(1) = ALAG1*100is deposited unshifted, each run byte-identical to the twin seeding from an ordinary parameter of equal value (nonmem_anchor/odes_init_dose_attr_{f,lag}_{A,B}.ctl). (Analytical models were left out of this first pass; they are covered by #1004 above.) Breaking for a model that foldsFinto the absorption flux — the pre-dose-entry convention the ODE docs’ migration note describes, which until now computedF²without complaint; the fix is to dropFfrom the right-hand side, or rename the parameter if it was never bioavailability. Note this makes ferx stricter than NONMEM, which allows a$PKF1to be referenced in$DESand quietly computesF²— so a mechanically translated control stream can newly fail to parse even though it ran in NONMEM. Anchored on NONMEM 7.6.0: twoADVAN13streams differing in one$DESline give predictions differing by exactlyF1, with no diagnostic from NONMEM (nonmem_anchor/dose_attr_double_use_{A,B}.ctl).
Fixed
A failed
ode_method = autofallback is no longer reported as a successful repair (#1080). When the stiff attempt is rejected and its pinned-rk45re-solve also stops before the segment end or returns non-finite output, theode_solverwarning now says that both attempts failed. The escalation guard also rejects a stiff attempt that stopped before the end of its segment — a method that exhaustsode_max_stepswithout ever reaching the minimum step size used to return its freeze-padded trajectory with every failure counter at zero. Solver statistics distinguish unfinished attempts discarded by the guard from unfinished trajectories actually returned to the caller, and the warning’s clauses no longer double-count a segment thatode_stiff_abort_afterabandoned.Hidden parameter guards now produce a fit warning (#1099). A free Theta at the implicit
1e-10/1e9cap, or an Omega, Omega-IOV, Sigma, or mixture override pinned to an internal packed-space safety limit, emits the typedparameter_at_runaway_guardwarning. FIX’d coordinates remain excluded; user-declared Theta bounds continue to use the separateboundary_estimatewarning. Lower hits are identified as collapse toward zero, while upper hits are identified as runaway estimates.A closed-form model with IOV and a
[scaling] y = <expr>readout evaluated the readout’s individual parameters atkappa = 0— predictions and the objective were silently wrong (#1079). The readout is the analogue of NONMEM’s$ERRORand is evaluated per record, so an individual parameter carrying akappa— an additive baselineBASE = TVBASE * exp(KAPPA_B), a second analyte, a bounded transform — must take that record’s occasion value. On the analytical (pk ...) engine it took the parameter’s typical value in every occasion instead: the concentration handed to the readout carried the occasion κ, but every parameter the readout itself read did not. On the reproduction in the issue the prediction is off by 12–20 %. Anyone who fitted such a model should re-run it — the reported estimates, OFV, and diagnostics were computed from the wrong predictions. The ODE engine was always correct, as was the compartment-free ([structural_model]-less) path, and ay = central / Vreadout was unaffected on every engine (theconc × Vamount reconstruction and the readout’s own/ Vcancel, with or without a κ onV) — which is why the natural readout to test could not show it. The analytic sensitivities are re-seeded to match, so FOCE/FOCEI/SAEM gradients now differentiate the corrected prediction.A third argument to a
[derived]row aggregate was dropped in silence (#1092).CMAX = max(IPRED, TIME > 0, 99)computedmax(IPRED, TIME > 0)— the aggregate form reads only the value and the optional row filter, and never looked at what followed. It is now a parse error that namesclamp(x, lo, hi), which is what the extra argument usually means. The same mistake written inside a larger expression was already rejected.simulate()silently removed an arm’s residual noise when itsweight = <expr>column was blank or zero (#1083).fit()has rejected a non-positive residual magnitude since #1029, but nosimulate()entry point ran that check — each ran its own subset of the model-vs-data checks and this one was in none of them. Because the modifier multiplies the additive loading, a zero weight did not blow up: it removed that arm’s residual variability entirely, and the simulated observation came back equal to its own IPRED — finite, plottable, and wrong. The weighted-kappa check had the same uneven coverage: present onsimulate_with_options*but absent fromsimulate/simulate_with_seedand from the adaptive-dosing path, whereκ/√Wwith a zeroWevaluates to zero rather than to infinity, so the arm quietly lost its between-arm variability with noNaNto trip over. Every simulate entry point now runs one shared list of checks; the two that return a bareVecand cannot signal enforce it as a panic, matching the existing IOV precondition.An estimated lagtime whose lagged dose arrival crossed a time-varying covariate change got a silently wrong analytic gradient on ODE models (#1060). The dose lands at a moving boundary
t + ALAG, and the walk injects the resulting jump as a saltation. The segment ending at that arrival belongs to the dose record’s covariate snapshot and the segment it opens to the next record’s, but the injection evaluated both sides on the dose record’s — so whenever those two records carried different covariates, the post-arrival velocity, its Jacobian and the cross term all used the wrong parameters. Individual predictions were unaffected (the correction is derivative-only) — but the FOCEI objective builds itshmatrix from this same analytic∂f/∂η, so the reported OFV was wrong too: on a crossing dataset the objective missed NONMEM 7.6.0 by 7.52 units before the fix and by 5e-6 after it, with every EBE reproduced to the printed digits (nonmem_anchor/tvcov_lag_saltation.ctl). Against finite differences of the production predictor the gradient error reached 300× and the Hessian 23×. Fixed with it: the matching defect at a finite-duration infusion’s lagged rate-on; concurrently active forcings missing from the boundary velocities, which cost a further ~2% of the second order once the two sides read different snapshots; and a value-only snapshot comparison that let an IOV occasion boundary at the rate-on drop a κ jet, giving∂²f/∂η_LAG∂κthe wrong sign. Constant-covariate fits, and any fit whose arrivals do not cross a covariate change, are bit-identical to before.TIMEin aninit(...)expression is now rejected instead of silently reading zero (#994). Both init surfaces —[odes] init(state) = ...and the analytical[initial_conditions] init(cmt) = ...— accepted a bareTIME(andtime), while rejectingTime,T,TAFDandTAD. An initial condition is evaluated at the time origin, soTIMEthere always read exactly0:init(central) = TIME + 50was bit-identical toinit(central) = 0 + 50, andinit(central) = TIME * SOMETHINGinitialised the compartment to zero, validated clean, and fitted. The leak was representational, not a scope decision — a bareTIMEparses to its own AST node rather than to a variable, so the undefined-name check could not see it. It now errors, pointing atd/dt(...)(or[scaling]Form C) for a genuinely time-dependent expression.MIXNUM, the other built-in the check cannot see, stays in scope: it resolves to the subject’s mixture class, so a class-switched baseline does real work — it is now named in the diagnostic, which previously listed neither.Every spelling of every clock in an
[odes] init(...)now gets the same explanation (#994).TIME,time,Time,T,TAFDandTADused to split three ways on representation alone — accepted, reported as a plain undefined name, or reported as an undefined name — so the message a user got depended on the casing they happened to type. All of them now report why a clock cannot appear in an initial condition. An expression carrying both a clock and an undefined name reports both problems from one parse instead of one per parse.A
[derived]row aggregate followed by more expression is now an error instead of silently dropping the rest (#1030 review).CMAX = max(IPRED) * 2parsed as the aggregate and discarded the* 2, reporting the unscaled maximum. There is no aggregate form that continues into a larger expression, so it now says so rather than returning a wrong number.A zero, negative, or non-finite residual-error magnitude is now rejected (#484 / #1029). The magnitude multiplies a sigma loading, so a zero one — an MBMA arm with no reported standard error, say — collapses that observation’s variance onto the internal
1e-12floor and lets a single row own the entire objective. Fits like that used to run and report a converged answer; they now stop atE_RUV_MAGNITUDE_NONPOSITIVE, naming the subject and TIME.A covariate used only by the residual-error magnitude is no longer frozen at the subject’s first value (#484, found while implementing #1029). A covariate that a model referenced only from an
[error_model]magnitude expression was not counted as model-referenced, so the pre-fit pass that drops unused per-record covariate snapshots discarded them for any subject whose only time-varying column was that one. Every observation was then scored with the subject’s first value of it — silently, with no diagnostic, and with the whole point of a time- or record-varying magnitude lost. Such covariates now register as referenced, so their per-observation snapshots survive.A sigma declared past the ones a
combined[error_model]names no longer perturbs the FOCE score or thes/rsrstandard errors (#1001 review).dr_diag_d_log_sigma’sCombinedarm answered everysigma_k >= 1with the additive sigma’s derivative, where itsadditiveandproportionalsiblings correctly returned zero. Since the loop runs over the whole flat sigma vector, a third, unreferenced sigma — legal, and documented as inert — picked up a non-zero∂R/∂log σit has no business having, putting a spurious row and column intosubject_nll_pop_gradand hence into the score cross-product behindcovariance = s/rsr. Affectedmethod = foce/gn/gn_hybridfits of acombinederror model with an extra declared sigma; the reported standard errors were wrong for every parameter, silently.examples/per_route_lag_absorption.ferxhad its two residual components the wrong way round (#1001 review). The file declaredsigma ADD_ERRbeforesigma PROP_ERRand wrotecombined(ADD_ERR, PROP_ERR); a single-endpointcombinedmodel consumes those positionally, so 0.02 was the proportional coefficient and 0.10 the additive SD — the inverse of the file’s own header, both inline comments, and the--simulateparameters it advertises. Both lists were transposed together, so the new #1001 check could not see it. Declarations and arguments are now in role order. Users who copied this example’s error model should swap it back the same way.An adaptive-dosing run now reads a
TIME-dependent[scaling]Form C readout on the same clock asfit()/predict()(#1028 follow-up). #1028 moved the readout’sTIMEto the raw data-file clock (the$ERRORconvention shared by sdtab,predict()/simulate()and[derived]windows) on the static predictors, but the reactive driver and the frozen-schedule replay verifier kept feeding it the integrator break the observation was keyed to. For a subject with stacked reset occasions — whose dataTIMErestarts while the internal timeline stays monotonic — those are different numbers, so the same record got oneTIMEundersimulate_adaptiveand another underfit(), and the replay verifier’s bit-equality against the static engine no longer held. Both now use the raw clock. Also in the same area: aT/tdeclared in[covariates]is now honoured case-insensitively (declaringTprotects atreference and vice versa — previously the case-mismatched pair silently folded to the model clock iny, and raised the time-in-obs_scaleerror against a legitimately declared column), and the declaration now reaches[adaptive_dosing] observe, which compiles through the same readout compiler — so a declaredTcan no longer be the data column in[scaling]and the clock inobservefor one model. TheT-fold warning forobserveis emitted at parse time, since the block is compiled at simulate time where there is no warnings channel.TIMEnow works in a[scaling]Form C readout, and an undefined name in[scaling]is no longer a silent zero (#1028). Ay = <expr>/y[CMT=N] = <expr>readout referencing theTIMEbuilt-in parsed fine but was never bound to the observation — the integrator’s model-time guard is dropped before the readout runs — soTIMEread0at every row and the whole time-dependent term vanished. A response-versus-time readout such asy[CMT=1] = EMAX * TIME / (TIME + T50)therefore fit, converged, and reported plausible parameters for a structural model nobody wrote.TIME(and theTalias[odes]also accepts) now resolves to each observation’s own time on both the ODE and analytical Form C paths, on the production predictor and the analytic sensitivity walks alike — and to each decision’s time in an[adaptive_dosing] observeexpression, which compiles through the same readout compiler — so the dummyd/dt(clock) = 1workaround is no longer needed (and is better dropped:clockstarts at the subject’s first record, not att = 0). Separately,obs_scaleexpressions never registered their covariate references as required data columns, andpredict()ran no covariate check at all, so an unresolvable identifier anywhere in[scaling]reached the predictor as the covariate map’s0.0default. Both halves of the block now register their references, andpredict()reportsE_MISSING_COVARIATEfor a missing column just asfit()andsimulate()already did. Breaking in two narrow places:obs_scale = TIME(or= T) is now a parse error naming Form C as the place for a time-dependent readout — the divisor is subject-static, evaluated once att = 0, so it could only ever have read0; and a[scaling]expression referencing an undeclared data column namedTnow reads the model-time built-in instead, matching[odes], where that name has always been reserved. DeclaringTin[covariates]keeps it a data column, and whenever the fold does happen ferx warns and names both escapes, so the substitution is never silent.TAFD/TADare unaffected and remain ordinary covariate references in[scaling]; when such a column is missing,E_MISSING_COVARIATEnow explains that the name is an[odes]-only built-in rather than reading as a plain typo report. The readout’sTIMEis the raw data-file clock — the same one sdtab,predict()/simulate()and[derived]windows report, and NONMEM’s$ERRORuses — which differs from the integrator timeline only for datasets with stacked reset occasions. A modified-release model whose closed-form fast path applies now declines to the ODE path when its readout readsTIME, instead of dropping the time term via a state-space linearity probe.predict()’s new covariate check accepts a name every subject’s covariate map carries even when the population’scovariate_nameslist is empty, so a programmatically built in-memoryPopulationkeeps working.An adaptive-dosing
dvmonitor no longer floors a negative Form C[scaling]readout at zero (#1039). The assay floor on theObserveMode::Dvpath (“an assay cannot read below zero”) was written when every monitored readout was a compartment amount or concentration, and was applied unconditionally after the residual draw. A Form Cy = <expr>readout is an arbitrary expression — a change from baseline, a difference from a comparator, a z-score, thesqrt(N) * logit(p)transform — so the same model read correctly undermode = ipredand came back as exactly0undermode = dvfor every negative sample, silently: a controller thresholding a change-from-baseline signal saw0over precisely the region it was written to react to, and dosed accordingly. The floor is now gated on the same predicate as the prediction path (#1020), so it applies only to the bare-state readout and to Forms A/B, which keep it. Withsigma → 0advmonitor again reproduces theipredmonitor sample for sample, negative samples included.SAEM no longer lets a fixed-effect-only theta drift away from the marginal optimum (#1011). The numerical θ/σ M-step assigned NLopt’s maximiser outright, re-maximising against a single MCMC η draw each iteration —
argmaxof one draw rather than the stochastic-approximation average ofE[argmax], a Monte-Carlo bias that does not decay with iteration count. A log-mu-referenced theta was unaffected (its closed-formlog θ += γ·mean(η)update is already an exact Robbins-Monro average), and Ω was already protected by a per-iteration SA cap; the θ channel was the one left exposed. The M-step result is now blended in asθ ← θ + γ_θ·(θ* − θ)withγ_θcapped at 0.03 during exploration and following the full decayingγ = 1/(k−k1)in convergence — the θ-side counterpart of the existing Ω cap. On the FREMiiv_on_ruvmodel of #1011, whose absorption fractionTVFRD1carries no ETA, SAEM moves from 0.039 to 0.290 against a marginal −2logL optimum of ≈ 0.29 (NONMEM IMP 0.394, ferx IMP 0.311, IMPMAP 0.318);TVMAT3.020 → 2.686 (NONMEM 2.680) and σ 0.213 → 0.170 (NONMEM 0.177). Damping applies only when NLopt is left estimating a theta that is not mu-referenced — the shape the bias was measured on, and the same condition the advisory below warns about. A theta that isFIX, that is pinned out by the mu-reference shift, or that is mu-referenceable at all (so the exact closed-formlog θ += γ·mean(η)update is available to it) stays on the undamped update, and a fit whose every estimated theta is one of those is bit-identical to before —warfarin_saem, all three of whose thetas are log-mu-referenced, is unchanged. Mu-referencing here means the pairing ferx detects: a parameter whose eta is attached in a form ferx cannot pair with a single theta (an additiveX = TVX + ETA_X, a covariate model that is not log-linear in one theta) counts as un-referenced and is damped, which is the intended side to err on — that theta has no closed-form shift either. Mixture models are excluded: aMIXNUM-switched typical value uses the same M-step but must separate from a common start before the class assignments settle, and damping that excursion stalls it (a 0.03 cap leftTVCL1 = 1.145against NONMEM’s 1.002 ontests/nonmem/mixture_iv_saem), so mixtures keep the undamped update. The bias is reduced, not removed, so the #1011 advisory still fires — attaching an ETA or holding the parameterFIXremains the better fix. The cap is exposed as themstep_damping[fit_options]key (default0.03, must be in(0, 1]); smaller damps harder, andmstep_damping = 1.0disables the damping entirely, restoring the previous behaviour exactly if a model fitted better without it. Setting it on a model it cannot affect warns rather than being silently ignored, and a value outside(0, 1]reachingrun_saemfrom a programmatic caller that bypassed the parser is clamped with a warning rather than applied (a negative damping would step theta and sigma away from the M-step optimum every iteration;+∞, which reads as “no damping”, now clamps to the1.0off value rather than to the tightest damping).mstep_damping = 1.0is a sentinel, not a cap value — the option is discontinuous there, since0.999still buys the full convergence schedule — and the no-effect warning now names which of the three reasons applies, including themu_referencing = falsecase it previously mis-described as “every theta is mu-referenced”. Sigma is blended with the sameγ_θon the same gate: theta and sigma come out of one joint NLopt solve, so damping only theta would leave sigma absorbing the misfit the damping just stopped theta from fixing.[covariate_nn]inputs were frozen at each subject’s baseline value when the covariate varied over time. The network reads its inputs from the same per-event covariate map every other consumer uses, but its input names are declared in the block rather than in an expression, so nothing registered them as referenced covariates. The fit pipeline then pruned their trajectories as irrelevant and the network saw one constant value per subject for the entire record — silently, with no error or warning, so a time-varying covariate simply had no effect on the fit. NN input names are now registered alongside[scaling]/ error-selector /[initial_conditions]covariates. The same registration also closes a second silent failure: a[covariate_nn]input the data does not carry — a typo, orinputs = [TIME](a reserved column, not a covariate) — used to be zero-filled, degenerating the network to a constant and producing a plausible-looking fit that had learned nothing. Such an input is now rejected at fit time withE_MISSING_COVARIATE, like any other missing covariate.
Added
[structural_model]accepts a compartment-free model — the$PREDequivalent (#811). A block with nopk .../ode(...)line and at least oneNAME = <expr>line declares its prediction directly, with no compartments underneath: write named intermediates above a finaly = <expr>, exactly as in[scaling], and reference thetas, etas, individual parameters,TIME, and any data column. This is the shape of every model-based meta-analysis structural model (a dose-response or time-course regression, not a PK system), which until now had to be written as a dummy compartment driven byd/dt(clock) = 1with the real equation hidden in a[scaling]readout. Such a model carries no doses and lays its individual parameters out like an ODE model, so it is not limited to the handful of spare parameter slots an analytical readout draws from. Blocks that presuppose compartments ([odes],[initial_conditions],[diffusion],[scaling]) are rejected by name rather than silently ignored. Such a model takes the analyticDual2gradient on both the inner and outer loops — with no state to integrate the sensitivity is a chain rule over the individual-parameter program — including under inter-occasion variability, where the equation is evaluated per occasion and the gradient seeded on that occasion’skappaaxes (what makes a between-treatment-arm variance component estimable rather than frozen). Finite differences remain only past the axis caps, and are reported as such. Anchored against the equivalent NONMEM$PREDfit: agreement to ~1e-4 relative on every estimate and standard error, and to 5 decimal places on the objective (nonmem_anchor/algebraic_emax.*). Seeexamples/emax_timecourse.ferx, andexamples/mbma_naproxen.ferxfor the published model-based meta-analysis case study (Boucher & Bennetts, CPT:PSP 2018;7:288–297) it reproduces to three significant figures.- Sample-size-weighted IOV:
weight = <expr>on akappadeclaration (#1031). The arm-level random effect of every longitudinal MBMA — between-treatment-arm variability — is distributedκ_ik ~ N(0, γ²/N_ik): a 400-subject arm’s mean wanders a quarter as far as a 25-subject arm’s. ferx could express that only by hand, inside a structural equation (... + ETA_EMAX + KAPPA_EMAX / sqrt(NARM)), which put a variance-structure decision where/ NARMinstead of/ sqrt(NARM)produces a plausible wrong answer rather than an error, and where nothing marks the term as weighted. Declare it where the rest of the variance structure is declared instead:kappa KAPPA_EMAX ~ 2.0 (sd) weight = NARM, and write the structural expression as... + ETA_EMAX + KAPPA_EMAX. The engine applies the scaling by rewriting the kappa asKAPPA / sqrt(W), which is exactly the hand-written form — so the objective, the analytic sensitivities, the EBEs and every estimator that supports IOV (FOCE, FOCEI, Laplace/AGQ, SAEM) are unchanged, and the reported Ω_IOV stays the unweighted γ² a published analysis quotes. The fit printout adds the number a reader actually needs next to it — the effective SD at the median arm,γ/√N— andFitResultcarrieskappa_weights/kappa_weight_typical. The weight may reference covariates,FIXed thetas andTIMEbut neither a random effect nor an estimated theta (both would make the divisor move underneath the up-front positivity check); a weighted kappa may be referenced only in[individual_parameters], and must be referenced somewhere in it (elsewhere it would read as the unweighted κ, and nowhere would report a scaling that was never applied);block_kappacannot carry one. A weight that is zero, negative or non-finite at any record — a blank arm-size cell — is rejected up front by bothfit()andsimulate()withE_KAPPA_WEIGHT_NONPOSITIVEinstead of dividing an individual parameter by zero mid-run, and one that moves within an occasion warns. [scaling]accepts named intermediates, expressions can be split across lines, andmin/maxtake two arguments (#1030). Three restrictions that individually looked defensible combined to make a standard bounded-endpoint readout unmaintainable: a model-based meta-analysis logit-Emax readout with the published[0.01, 0.99]clamp came out as one ~200-character line with the same sub-expression written four times, because there was nowhere to name it. Now: (1) any[scaling]line whose key is notobs_scale/ydeclares a named intermediate usable by the entries below it, following the same define-above-use-below rule as[individual_parameters]— intermediates are inlined, so covariates reached only through one are still required data columns and the dose-attribute double-use rejection still sees them; (2) a long expression may be continued across lines by starting the continued line with an operator or ending the previous one with it, in[individual_parameters],[odes],[scaling],[derived], and[initial_conditions]; (3)min(a, b)andmax(a, b)are available everywhere the DSL parses expressions, desugaring to the inline conditional so they differentiate and compile exactly like the hand-writtenif (a >= b) a else b— each argument appears twice in the desugared tree, so clamp a named intermediate rather than a long expression. In[derived], wheremin/maxalso name the row aggregate, the two are told apart by the second argument: a comparison is a row filter, anything else is the numeric clamp. The three-line readout now reads the way the published Mlxtran does. Two smaller diagnostics come with it:max(a, b)used to reportMissing closing parenthesis for function max, which sent the reader after a bracket bug that did not exist — arity errors now say so — and a[scaling]key that is neitherobs_scale/ynor read by any entry is rejected, so a misspeltobs_scal = Vstill fails loudly instead of silently disabling scaling.- First-class residual weighting:
weight = <expr>on the error model (#1029). Meta-analysis rows are trial-arm summaries that differ in precision, and inverse-variance weighting is what makes an MBMA an MBMA. Until now it had to be hand-built in three places that must agree — divideDVin R, divide the prediction again in[scaling], and pinsigmato 1 in[parameters]— with the side effect that every diagnostic ferx ships (sdtab, VPC, obs-vs-pred, residual-vs-time) came out in weighted units nobody can read. Declare it once instead:DV ~ additive(ADD_ERR) weight = WPSE.DVstays on the natural scale in the data, andPRED,IPRED,CWRES,sdtaband the VPC are all reported on the natural scale with no back-transformation. The modifier means “score as ifDVand the prediction were both divided by the weight”, so the additive loading picks up a factorWand the proportional loading is untouched (a common scale factor cancels out of a constant-CV error) —weight =on a purely proportional model is therefore rejected rather than silently doing nothing. Whethersigmais fixed stays your call, because both conventions are real: fix it to 1 for a continuous endpoint with a known SE, estimate it for a weighted logit of a responder proportion (σ absorbs thep(1-p)factor). One caveat when reconciling with a published run: ferx scores on the natural scale, so its OFV differs from the hand-built one by the change-of-variable constantΣ 2·ln(weight)— data-only, identical across models on the same rows, and cancelling out of every ΔOFV, LRT and AIC comparison. Not yet supported with per-CMT error models, inside a covariate-selectedif/else. - Residual-error magnitudes now work under every estimator (#484 / #1029). A
weight = …modifier — and any[error_model]sigma written as an expression of TIME / covariates / thetas — used to be rejected up front for anything butfoceandfocei, because SAEM’s M-step, the Gauss-Newton BHHH gradients, the importance-sampling likelihood and proposal, and the IOV individual likelihood (SAEM’s E-step, the Bayes MH target) all read the residual variance through call sites that never applied the per-observation multiplier. They all thread it now, so FOCE, FOCEI, Laplace/AGQ, SAEM, IMP, IMPMAP, GN, GN-hybrid and Bayes score the same weighted likelihood andmethod =chooses the algorithm rather than the model. Gradients keep up: SAEM’s M-step carries the magnitude’s direct θ channel (a θ that appears only in the error model used to read a gradient of exactly zero there), and the Gauss-Newton closed forms — which have no such term — route a θ-dependent magnitude to their magnitude-aware finite-difference fallback. A weight built from covariates and TIME alone, which is every #1029 model, has no direct-θ channel and keeps the fast analytic path everywhere. - SAEM now warns when an estimated theta carries no ETA at all. A fixed-effect-only theta is not mu-referenced, so it never gets the γ-damped closed-form
log θ += γ·mean(η)update and is moved only by the η-frozen numerical M-step — which re-maximises against a single MCMC η draw with no stochastic-approximation damping and can drift far from the marginal optimum, dragging correlated typical values with it. SAEM previously said nothing (its existing advisory only covers a theta whose ETA could not be mu-referenced); IMP/IMPMAP have warned about this since #406. On a FREMiiv_on_ruvmodel whose absorption fractionTVFRD1has no ETA, SAEM drove it to 0.039 while IMP (0.311), IMPMAP (0.318) and NONMEM IMP (0.394) agree, withTVV+6% andTVMAT+9% carried along; restarting SAEM at the IMPMAP solution still walked it down to 0.065. AddingFRD1 = TVFRD1*exp(ETA_FRD1)(ω² = 0.01) recovers 0.313, and holdingTVFRD1FIXputs every other theta within 3% of NONMEM. The advisory names the remedy that fits the parameter — put a typical value in a mu-referenceable form, or hold a covariate coefficient / allometric exponent / structural constantFIXand cross-check against FOCEI/IMPMAP — and stays quiet about thetas for which “has no ETA” would be false: a mixture’s mixing coefficients (never moved by the numerical M-step, and forbidden from depending on an eta at all), andMIXNUM-switched class typical values or identity-scale-dropped log-mu-references, which carry an eta and already get their own message. See SAEM: non-mu-referenced parameters. - Class-aware mu-referencing for mixture models (#996). A
MIXNUM-switched typical value written asCL = if (MIXNUM == 1) TVCL1 * exp(ETA_CL) else TVCL2 * exp(ETA_CL)is now recognised at parse time and resolved to one anchor theta per class (any number of classes; a trailingelsecovers every class the chain does not name). Estimating IMP / IMPMAP uses it to apply the EM responsibility-weighted shiftlog θ_k += (Σ_i PMIX_ic · η̄_ic) / (Σ_i PMIX_ic)and pins those θ out of the importance-weighted M-step. This closes the accuracy gap that made IMP the noisiest mixture estimator: on the two-class anchor IMPMAP now recoversTVCL1 = 0.952,TVCL2 = 2.821against0.949/2.819from NONMEM IMPMAP withMU_1assigned inside theMIXNUMbranch (tests/nonmem/mixture_iv_impmap_mu.ctl, NM 7.6.0) — under 0.5 % on every estimated parameter, versus2.60forTVCL2before. SAEM gains the closed-form shift for class-shared typical values (V = TVV * exp(ETA_V)inside a mixture), which previously fell back to the numerical M-step; its class-switched θ deliberately stay numerical, because SAEM’s hard per-subject class draw makes the per-class η mean a biased classification-EM statistic (measured:TVCL22.75 → 3.02, OFV 302.2 → 305.0).mu_referencing = falserestores the pre-#996 behaviour for both estimators. AMIXNUM-switched expression that cannot be resolved to a class-aware anchor now warns instead of silently dropping to the numerical M-step. - Models with no random effects — fixed-effects-only / naive-pooled fits (#989). A continuous (residual-error) model may now omit every
omegadeclaration; previously this was rejected at parse time withNo omega parameters defined, and the 0×0 Ω path was reachable only from an[event_model]/[binary_model]endpoint. Withn_eta = 0there is no inner empirical-Bayes problem and nolog|Ω|term, so FOCE/FOCEI collapse to the plain maximum-likelihood objective. An[error_model]and itssigmaare still required for a continuous endpoint — only Ω becomes optional. Anchored against a NONMEM$OMEGA 0 FIXpopulation fit ondata/one_cpt_iv.csv(OFV −269.637010 vs −269.63700440; θ and σ to 4+ significant figures; SEs match undercovariance_method = rsr, which is NONMEM’s$COVARIANCEdefault).method = saemis rejected atn_eta = 0(E_SAEM_NO_RANDOM_EFFECTS);gnruns but is not recommended, since without an inner loop it is unusably sensitive to thesigmastart — usegn_hybridorfocei. An unresolvedETA…/KAPPA…identifier is now reported as an undeclared random effect (E_ETA_NOT_DECLARED, orW_ETA_NOT_DECLAREDwithout--data) rather than a missing covariate, so deleting anomegaline and leaving itsexp(ETA_…)term behind fails loudly. - Fixed-effects-only fits no longer print an empty
OMEGA Estimatessection (#989). Atn_eta = 0the header used to be written above an empty body, which reads as an estimation that failed rather than one that was never requested. - IMP / IMPMAP estimation and objective evaluation for mixture models (#985). Importance sampling now both estimates a
[mixture]model (method = imp/impmap) and evaluates its class-marginal likelihood−2 Σ log Σ_k p_ik L_ik(imp_eval_only, NONMEMMETHOD=IMP EONLY=1). Estimation runs a class-partitioned MCEM: per subject and class it importance-samples η under theMIXNUMguard, forms the responsibilitiesPMIX_ik ∝ p_ik L_ik, and runs responsibility-weighted M-steps for the mixing coefficients, the class-shared Ω, and the class-switched typical values. Objective evaluation combines the per-class IS marginals by log-sum-exp and matches NONMEM to well under a Monte-Carlo SE (−2 log L = 300.82 ± 0.06vs NONMEM300.87on the two-class anchor). Ω/σ are class-shared (per-class overrides rejected); IOV/FREM/SDE are not supported under the mixture IMP paths, and a theta shared between the mixing expression and a structural typical value is rejected (as under SAEM). The per-class draws use the same ISCALE pilot search andimpmap_mcetamulti-start MAP as the single-population MCEM; objective evaluation reports a real per-subject ESS (the worst ESS among the classes a subject loads on), soimp_low_ess_thresholdand the proposal-collapse warning apply to mixtures.impmap_traceandimpmap_auto/imp_autoare not wired for mixtures and now warn instead of being silently ignored. - Bayesian estimation for mixture models (#985).
[mixture]models can now be fit withmethod = bayes. The latent class is Rao-Blackwellised — marginalised out of every Gibbs block rather than sampled — so each subject’s likelihood contribution is the K-class marginal−log Σ_k p_ik·exp(−nll_ik)and the sampler keeps a smooth continuous target. The η block samples against the class-marginal posterior (HMC is disabled for mixtures; its analytic gradient is single-class), the (θ,σ) block samples the mixing thetas (constant or covariate logit) with no extra machinery, and Ω is drawn from its class-shared conjugate conditional. Reported OFV, per-subjectMIXEST/PMIX, and EBEs are the K-fold marginal values at the posterior mean. Per-class Ω/σ overrides are rejected (Ω/σ are class-shared). Validated by recovering the mixture MLE under diffuse priors (a direct NONMEMMETHOD=BAYESreference is impractical — its sampler aborts on$MIXwith FIXed Ω/Σ). Inter-occasion variability is supported: the κ block samples against the same class-marginal target. A run that asks for HMC (saem_n_leapfrog > 0) on a mixture now warns that the Metropolis-Hastings η kernel was used instead, and a high R̂ on a mixture warns about label switching, which would make the reported posterior mean an average across class labels. - SAEM estimation for mixture models (#985).
[mixture]models can now be fit withmethod = saem, not only FOCE/FOCEI. The E-step samples the latent class per subject (from the current posteriorPMIX_i) and runs the η-MCMC within the drawn class; the M-step estimates the class-switched typical values (each from its own class members), the per-class Ω overrides, and the mixing coefficients (constant or covariate-dependent logit mixing) from the sampled class frequencies. Reported OFV, per-subjectMIXEST/PMIX, and standard errors come from the K-fold mixture marginal, matching FOCEI. Cross-checked against NONMEMMETHOD=SAEM(estimates agree to ≤ 3 %). A mixing theta markedFIXis honoured; a theta shared between the mixing expression and a structural typical value is rejected with a clear error. Per-class σ overrides (sigma(k)) are held at their initial values under SAEM (with a warning, and reported with SE 0 like other fixed parameters) — route those to FOCEI. - Inter-occasion variability under a mixture (#985). A
[mixture]model may now also carry akappa(inter-occasion variability) term — the two features compose, where beforefit()rejected the combination. Each class’s per-subject inner solve estimates the per-occasion κ̂ under that class’s typical values, and the FOCE/FOCEI marginalL_ikis the κ-augmented occasion likelihood, so the usualL_i = Σ_k p_ik · L_ikmixture is formed over IOV-aware class likelihoods. The IOV Ω is shared across classes (matching NONMEM$OMEGA BLOCK(1) … SAME). The analytic outer gradient does not yet emit κ-slot derivatives for a mixture, so an IOV mixture optimises against a finite-difference outer gradient; the covariance step runs as usual on the K-fold objective. Per-subject diagnostics are IOV-aware: the winning (MIXEST) class’s per-occasion κ̂ flow into the sdtab IPRED/IWRES/CWRES and per-subject OFV, into κ shrinkage, and into the.fitrxebe_kappasexport. Cross-checked against NONMEM 7.5.1 (tests/nonmem/mixture_iv_iov.ctl): OFV, class clearances,V, mixing fraction, and all 30MIXESTclassifications agree. - Mixture models —
$MIXTURE-style discrete latent subpopulations (#977). Model files can declare a[mixture]block giving the number of classes (nsub), the per-class mixing rule (logit(k) = …softmax, orp(k) = …direct probability, over theta + covariates), and optional per-class Ω/Σ overrides (omega(k)/sigma(k)); the reserved read-onlyMIXNUMindex (1..=K) selects class-specific typical values inside[individual_parameters].fit()estimates such models by FOCE / FOCEI — each subject’s marginal is the covariate-weighted mixtureL_i = Σ_k p_ik · L_ikand the objective is the numerically stable log-sum-exp−2 Σ_i log Σ_k p_ik exp(−nll_ik), with a separate empirical Bayes solve per (subject × class). The mixing-logit coefficients are ordinary thetas and the per-class Ω/Σ are estimated jointly. Estimation defaults to the derivative-free (BOBYQA) outer optimizer, but an analytic posterior-weighted outer gradient is available, so a user-selected NLopt gradient optimizer (SLSQP / L-BFGS / MMA) is honoured — with an automatic finite-difference fallback for models outside analytic scope (e.g.MIXNUM-branched typical values). Other estimators (SAEM/IMP/Bayes) are not yet supported and error clearly (inter-occasion variability is supported since #985). The parser rejectsnsub < 2,MIXNUMassignment,MIXNUMoutside a mixture model, eta-dependent mixing expressions, missing class coverage,omega(k)on a block base, and overrides of the base class. Thesdtaboutput gains per-subjectMIXEST(most-probable class, 1-based like NONMEM) andPMIX_1..PMIX_K(posterior class-membership probabilitiesPMIX_ik ∝ p_ik·exp(−nll_ik)) columns for a mixture fit. - Standard errors / covariance for mixture fits (#983). The covariance step now runs for mixture models: its finite-difference Hessian is built on the K-fold mixture objective (
−2 Σ_i log Σ_k p_ik exp(−nll_ik)), not the single-population marginal, so a mixture fit reports SEs, RSEs, and a covariance matrix like any other fit. The mixing-fraction SE is reported on the scale the mixing form is parameterized in — the coefficients of alogit(k) = …form on the logit scale, ap(k) = …probability directly — since those coefficients are ordinary thetas. The covariance-matrix labels now name the per-class Ω/Σ override coordinates (omega[<eta>_MIX{k}]/sigma[<sigma>_MIX{k}]) instead of a genericpacked[N]. - A missing
DVno longer empties a simulation. Simulating from a design — dosing plus sampling times, withDV = .because the values are what the run is about to produce — used to return zero rows: everyEVID=0row with a missingDVwas skipped as a forgottenMDV=1(#258), which is the right reading only when theDVis an input. The newread_population_for_simulation()reads such a row as a design point instead, so the natural template simulates as written and no placeholder number is needed in the column about to be overwritten. Fitting is unchanged, andMDV=1still excludes a record on both paths (#957). Kept design rows are reported asW_DESIGN_DV, the simulation-side counterpart ofW_MISSING_DV, so a simulation run off an observed dataset makes its extra rows visible rather than silently carrying more rows than a fit of the same data.
Changed
- API (breaking for struct-literal construction):
MixtureSpecgained amu_refsfield and is now#[non_exhaustive](#996). The new field carries the class-aware mu-references described above. Downstream Rust code that built aMixtureSpecwith a struct literal will no longer compile — read its fields, or obtain one from the parser, instead.#[non_exhaustive]makes the next field addition non-breaking. No effect on.ferxmodels, the CLI, or the R wrapper, neither of which constructs the struct.
Performance
The ODE inter-occasion-variability (IOV) analytic sensitivity path now compiles from a bucketed set of dual widths instead of one specialisation per stacked axis count, cutting the crate’s generated LLVM IR by 43 % (17.4 M → 9.8 M lines) and the lib’s
-Ztime-passestotal by ~2.95× locally (376.7 s → 127.5 s) — the direct attack on the compile-bound CI wall clock tracked in #969. Gradients are unchanged: a stacked width that is not a bucket boundary is padded with zero lanes and returns bit-identical∂f/∂η/∂f/∂θto the exact-width walk. Widths up to 24 axes — the ordinary IOV model — are still specialised exactly, so they pay no runtime cost at all; wider subjects run up to ~1.5× more work in the outer walk for the padded lanes, and the 96-axis cap (past which a subject falls back to finite differences) is unchanged (#971).Deep compartment models with IOV no longer fall back to finite-difference η-gradients.
[covariate_nn]weights are auto-generated thetas, somodel.n_theta(declared + weights) can never equal the compiled[individual_parameters]program’s θ-axis count (declared only — the program can only reference θ by name). The analytic IOV predicate required them equal, so every subject of every DCM+IOV fit took the slow path: 60 of 60 on a busulfan-shaped model, correct but roughly twice the necessary runtime. That clause is load-bearing for the outer gradient, which seeds θ axes, but not for the inner η-gradient, whose walk documents that it uses no θ axes and reads only the η block. The two predicates are now separate, and the inner loop’s own gates — the ones that decide the route and the ones that report it — read the η-only one, so FOCE/FOCEI, AGQ and the reportedgradient_method_innerall agree on the route taken. Measured on that fit: 336 s → 162 s (2.07×), withn_fd_subjects60 → 0 and the objective unchanged at 1060.99 after an identical 14 625 iterations. Models that already had the analytic path keep it byte-for-byte — the η-only route is taken only where the full one was unavailable.Analytic weight gradients for
[covariate_nn]models — the fixed-η θ gradient that SAEM’s M-step, IMP and VI share used one perturbed model solve per θ, and on a deep compartment model the network’s weights are θ. It now takes the network’s weights analytically: the NN reaches the likelihood only through its output layer, so∂NLL/∂w = Σₖ (∂NLL/∂zₖ)·(∂zₖ/∂w), where the second factor is exact backpropagation and only then_outputsvalues of the first need the model solved. Those are obtained from the output-layer biases, which move one output’s pre-activation and nothing else. On the reference 141-weight DCM (2 → 8 → 8 → 5) that is 10 solves per subject per draw instead of 141. Because the few remaining differences are now shared by every weight, they are taken centrally rather than forward, and agreement with a central finite difference of the objective improves from ~2e-5 to ~1e-9 relative — the weight gradients stop being the least accurate part of a DCM fit. Wall-clock on that model is ~1.4× (42 s → 30 s at a fixed 1500 VI iterations, estimates unchanged); the finite-difference loop was roughly 30% of VI’s runtime there, so the 14× cut in solves does not carry through to the total. Models whose NN inputs vary within a subject keep the per-θ loop — a single output vector no longer mediates every observation — and models with no[covariate_nn]block are untouched.
Added
vi_sigma_update—σcan now be replaced each iteration by the exact ELBO maximizer rather than stepped by Adam, the same treatmentΩalready gets fromvi_omega_update = closed_form. For a single proportional or additiveσ, stationarity of the data term givesσ*² = (1/n_obs) · Σ E_q[(y − f(η))²/f(η)²], and the same identity expresses that sum in terms of theσgradient the ELBO already computes, so the update costs nothing.closed_formis the default;adamrestores the previous behaviour. Error structures with no scalar stationary point (combined error, severalσ, per-endpoint or covariate-selected error, correlated residuals, M3 BLOQ, IIV-on-RUV, FREM, or a FIXedσ) fall back to Adam with the reason recorded inFitResult$warnings.What this does and does not buy. It makes
σexact givenqand removes it from the stochastic trajectory, soσno longer carries its ownvi_lrsensitivity. It is not what closes the warfarin gap below — atvi_mc_samples = 128the two routes agree to 0.3 OFV, and Adam is marginally ahead. Adopted for exactness and consistency withΩ, not for a measured improvement at converged settings.
Changed
- The documented size of VI’s posterior-variance understatement is now measured rather than cited.
docs/estimation/vi.qmdpresented “on the order of 20–25%” as a general figure; it is the number for deep compartment models. Measured against per-subject NUTS at a fixed population estimate, ferx’s variational posterior matches the exact posterior to 0.2% in variance on warfarin (means to2×10⁻⁵), and to 4% when thinned to two observations a subject. The Laplace covariance matches NUTS to 0.1% on the same fits, so the true posterior is Gaussian there and a Gaussianqhas nothing to get wrong. The page now reports the measurements and scopes the citation to the regime it came from.
Fixed
method = vino longer reportsconverged: trueafter ~500 iterations when every population parameter is FIXed. Fittingqalone at a pinned(θ, Ω, σ)— how you read per-subject posteriors at a known estimate — is a legitimate request, but VI’s parameter-stability convergence test was comparing a vector that cannot move against itself, reporting “settled” at its first opportunity and stopping the run. Since either convergence criterion is sufficient, that overrode the objective test, which had correctly reported “still moving”. On warfarin with everything FIXed at a known-good estimate the fit stopped after 500 iterations withelbo_tightness_ratio: 78(implausible above 25) and−2·ELBO = +2026, on a model that reaches−283onceφconverges; it now runs 6250 iterations to−282.6with a ratio of1.4. When no population coordinate is free, convergence is judged on the objective alone and a warning says so.
Changed
method = vineeds more Monte-Carlo draws than the default provides. Ondata/warfarin.csv(~1% proportional residual),vi_mc_samples = 8(the default) returnsσ ≈ 0.0138against0.010565from both AGQ (n_agq = 9) and FOCEI — 31% high — with the OFV 9.4–9.6 units short of AGQ’s−285.977. Because per-subject posterior width scales withσ², every variational covariance reported at that point is ~1.7× too wide. The cause is the convergence rule meeting its own noise floor: it stops when the ELBO’s drift is no longer distinguishable from Monte-Carlo noise, and at 8 draws that happens while real drift remains — so the fit stops short andσ, the slowest-moving coordinate, is left furthest from its optimum. Raising the draw count resolves it (32→ −284.55,128→ −285.61 against AGQ’s−285.977), and loweringvi_lrdoes too. Starting from a fitted FOCEI point does not help. Documented indocs/estimation/vi.qmd. The default has since moved from 8 to 32 for this reason (see the entry above). If a VI fit lands well short of a FOCEI or AGQ fit of the same data, raisevi_mc_samplesfirst.
Added
- The per-subject variational posterior is now written to the fit YAML. A
method = vifit emits aneta_posteriorblock undervi:, keyed by subject ID, carrying each subject’s variational mean and its full covariance (plus per-occasionkappameans under IOV). PreviouslyFitResult$vi$eta_means/eta_covswere reachable only from the Rust and R APIs, so the CLI could not support any per-subject comparison. Note the covariance is the variational one, which understates the true posterior variance — seedocs/estimation/vi.qmd. agq_eval_only— alaplacestage can now evaluate the adaptive-Gauss-Hermite marginal likelihood at the parameters it is handed instead of estimating, reporting it asofvand leavingθ/Ω/σuntouched. It must be the final stage. This is the deterministic counterpart toimp_eval_only:method = vi, laplacewithagq_eval_only = trueturns a VI fit’s ELBO — which is only a lower bound — into a real−2 log Lthat carries no Monte-Carlo error, so two runs agree bit for bit. Seedocs/model-file/fit-options.qmd.
Fixed
optimizer = trust_regionno longer reportsConverged: YESwhen it merely ran out of iterations (#1000). The underlying solver has no convergence criterion of its own, so exhaustingmaxiterwas its only way to stop — and every such run was labelled converged, with standard errors computed at a non-stationary point (on theone_cpt_iv_pooledzero-Ω anchor, 8 000–11 000 OFV units short of the optimum). The trust region now stops when it can no longer make progress — no objective improvement beyondouter_ftoland no parameter movement beyondouter_xtolfor 20 consecutive iterations — and a run that instead hitsmaxiterreportsConverged: NOwith a warning naming the budget and the gradient norm it stopped at. Fits that do settle now also stop as soon as they settle instead of grinding out the remaining budget (the warfarin fit returns at iteration 59), and the trust-region path now reportsfinal_gradientand the number of outer iterations it actually ran (it reportedIterations: 0before).outer_ftolandouter_xtol, previouslybobyqa-only, now also governtrust_region;outer_ftolresolves the same way for both, so a pure-TTE fit gets the #4691e-8tightening rather than a looser hardcoded value. Two verdicts are newly explicit: amaxiterbelow 21 cannot demonstrate settling at all (the warning says so instead of blaming the fit), and a run that rejects every step from the first iteration is reported against its starting values and bails out immediately rather than spending the whole budget frozen. Note for multi-start users:n_startsprefers a converged candidate over a non-converged one, and that key was inert while everytrust_regionrun claimed convergence — a settled start can now be selected over a better-OFV start that ran out of budget.optimizer = trust_regionundermethod = foceino longer descends on a truncated gradient. The trust region used the fixed-η̂ score2·Σ gᵢ, which drops thelog|H̃|EBE-response termtᵢ(#274/#289) that the Gauss-Newton path already adds — the omission a gradient optimizer stalls above the minimum on. It now uses the same2·Σ (gᵢ + tᵢ)marginal gradient, pinned against central finite differences of the objective in a unit test. FOCE and additive-error fits are unchanged bit-for-bit (tᵢis identically zero there); on warfarin the FOCEI fit now reaches OFV −286.0042, the same valuenlopt_lbfgsconverges to, instead of settling 4.1 units above it at −281.88.final_gradientis now the marginal gradient rather than a quantity that stays large at the optimum.optimizer = trust_regionis now rejected on a quadrature stage (E_OPTIMIZER_AGQ) instead of silently fitting the wrong objective.method = laplace(anyn_agq) andmethod = foceiwithn_agq > 1minimise the adaptive-quadrature marginal, and every optimizer scores it — but only the NLopt and BFGS paths route the matching gradient (agq_population_gradient). The trust region has its own gradient with no quadrature branch, so it descended on the FOCE/Laplace closed form while reporting quadrature OFVs: a fit that converged, smoothly, to the FOCE optimum with no warning.ferx checkandfit()now both reject the combination and name the optimizers that do support it. Full quadrature support in the trust region needs a BHHH Hessian built from per-subject quadrature scores and is tracked in #1047.A transit / inverse-Gaussian absorption model no longer panics mid-fit when its ODE fallback cannot be built (#1008). The analytic
one_cpt_transit/two_cpt_transit/one_cpt_ig/two_cpt_igclosed forms carry a synthesized ODE twin that serves the subjects the closed form cannot (time-varying covariates, aTIME-dependent parameter, IOV, steady-state or infusion doses, the flip-flop regime). The twin is an ODE model while the model you wrote is analytical, so parse checks that apply only to ODE models run on the twin and not on your model — and a model ferx accepted could produce a twin ferx rejected. That surfaced as an internal panic duringfit()/predict(), the first time a subject actually needed the fallback. The twin is now built at parse time: if it cannot be built the model simply stays closed-form and aW_ABSORPTION_TWIN_DECLINEDwarning (absorption_twin_declined) reports the reason, and any subject that needs the fallback is rejected up front with an explicit message instead of crashing. That rejection quotes the reason too — previously the twin-less messages described only the desugar’s scope limits (“an unrecognised closed form”, “outside the automatic ODE-equivalent rewrite”), which is the wrong cause for a twin that was built and rejected, and the actionable reason never reachedfit()’sErrorpredict()’s panic. A live example: an individual parameter namedCENTRAL(orPERIPH), which collides with the twin’s own state names.A negative Form C
[scaling]prediction is no longer silently clamped to zero on ODE models (#1020). The ODE predictor applied its negative-prediction guard to the final prediction vector — after they = <expr>/y[CMT=N] = <expr>readout had been evaluated. That guard is a statement about a compartment amount (which cannot go below zero, so a negative value is solver overshoot), but a Form C readout is an arbitrary user expression that is often legitimately signed: a change from baseline, a difference from a comparator, a z-score, or thesqrt(N) * logit(p)transform used in model-based meta-analysis of a bounded endpoint, which is negative for every arm below 50%. Every such prediction came back as exactly0, with no warning and nothing in the fit output to show it — the fit converged with the residual σ inflated to absorb the mismatch and the between-subject variance collapsed. The clamp now applies only to the default bare-state readout (obs_cmt, and the analytical PK concentration), matching the analytical Form C path, which never clamped its readout.NaNis still never clamped on either path, so a bad scale or a missing per-CMT entry keeps surfacing as aNaNobjective. Applies to every ODE driver (dense, dense-with-states, event-driven, adaptive-dosing replay) and to the analyticDual2/Dual1sensitivity walks, whose clamp is gated identically so the analytic gradient still matches finite differences of the predictor.SIR no longer fails with “All SIR samples had invalid weights” on a rank-deficient covariance (#1021). A parameter direction the data do not identify comes back from the covariance step with a variance around
1 / eigenvalue floor— thousands of standard deviations in packed log-space — so every proposal draw landed outside the parameter bounds and was rejected. Each proposal direction is now capped so ±2 standard deviations stay inside the room between the estimate and its nearer packed bound, near-null directions (a likelihood ridge left over afterFIXed parameters are excluded) are floored rather than fatal, and both cases are reported asSIR:warnings naming the parameters involved. When every sample is still rejected, the error now reports the rejection tally, the coordinates whose bounds were hit, and the proposal’s rank deficiency instead of the bare message. This is the common model-based meta-analysis case, where fixing the residual variance is the weighting scheme and cannot be dropped.Simulating an IOV (
kappa) model with parameters that carry no IOV covariance now reports an error instead of panicking (#1019).simulate()draws one κ per occasion fromomega_iov; a caller that rebuildsModelParametersfrom a fit and drops that block (the Rferx_simulate(..., fit = f)bridge did) hit anexpect()deep in the row emitter, which crossed the FFI boundary as a process panic.simulate_with_options/_diagnow return a cleanErrnaming the missingomega_iovand the fix; theVec-returningsimulate/simulate_with_seedfail loud with the same message rather than emitting rows with no inter-occasion variability.Log-mu-referenced θ with a negative lower bound no longer takes the wrong closed-form update (#996). Such a θ is packed on the identity scale, so the SAEM/IMP
log θ += mean(η)shift was not its EM optimum — it appliedθ += mean(η)where the closed form meansθ *= exp(mean(η)). It is now excluded from the closed-form channel and estimated by the numerical / weighted M-step instead, with a warning naming it, on the mixture and the single-population IMP/IMPMAP path.A collapsed mixture class no longer freezes its typical values (#996). When no subject carried any responsibility for a class, its class-aware mu-ref shift was undefined but the θ stayed pinned out of the weighted M-step, so it could never move again for the rest of the fit. Those θ are now left free for that iteration and the fit warns that the class received no responsibility mass.
The “no associated ETA” advisory (#406) is no longer suppressed for mixture class thetas whose class-aware shift is switched off (#996) — under
mu_referencing = false, for identity-packed θ, or for a random effect with negligible variance those θ really are estimated by the importance-weighted M-step alone, and the fit now says so.Fits no longer stall on their initial estimates under the default optimizer (#751). A fit whose opening line search fails could previously quit a hair off its starting point — the user-ODE warfarin twin stopped 35 OFV units short of the optimum — and report the initial estimates, with their standard errors, as the result. Such a fit is now automatically re-run once with the identity-Hessian overshoot guard held on until it escapes its starting point; the second attempt is reported when it both left the initial estimates and reached a lower objective, and otherwise the original result stands. Affected models reach the same optimum, and the same NONMEM-validated standard errors, as their analytical twin. Fits that were already leaving their initial estimates are unaffected — they never trigger the retry and their trajectory is unchanged.
A fit pinned at its initial estimates is no longer reported as converged (#751). The plateau rule that reclassifies a bare NLopt
Failureat a flat optimum as convergence now also requires the fit to have left its starting point. A stalled fit that booked one small objective improvement and then went flat previously satisfied the rule and was flaggedconverged, which also handed the covariance step a non-stationary point.Inner EBE no longer certifies a runaway mode, so the FOCE/FOCEI objective is gradient-path-independent (#958). On a model where a prediction is driven toward zero under proportional error, that observation’s variance is clamped to the floor and its residual term amplifies the ODE solver’s local error by ~1e8 — enough for the finite-difference inner gradient to come back with the wrong sign. The inner search then walked tens of prior SDs away from η = 0 and accepted the point it landed on, because a noise-driven search satisfies the objective-stall convergence test exactly like a converged one.
gradient = fdandgradient = autotherefore reported different objectives for the same model at the same estimates, which made ΔOFV/ΔAIC model selection depend on the gradient route. The inner loop now re-solves derivative-free from the prior mean whenever the returned EBE’s Mahalanobis distanceηᵀΩ⁻¹ηexceeds 10 prior SDs per random effect, and keeps whichever point has the lower objective. On the reported reproducer the first-evaluation objective goes fromfd6082.24 /auto−5.478 to −5.479 / −5.478, and both routes now recover the simulating parameters. The analytic (Dual2) sensitivities were correct throughout — verified against finite differences of the predictor to 2.6e-7 — sogradient = auto, the default, was never affected.gradientoption error no longer advertises the retiredadroute (#958). The parse-time message for an unknowngradientvalue listed'auto','ad', or'fd', but no fit acceptsad— the Enzyme automatic-differentiation path was retired in #428 and the engine rejects it — so a mistyped option pointed users at a setting that cannot work. The message now lists only'auto'and'fd'. Writinggradient = adstill parses, so it continues to reach the engine’s specific “no longer supported” error rather than a generic one.Per-subject diagnostics now honour
MIXESTin a mixture fit (#985).IPRED,PRED,IWRES,CWRES,EBE_OFV, and[derived]/[output]columns were computed withMIXNUMpinned to class 1 for every subject, even for subjects the fit assigned to another class — so a class-2 subject’s sdtab row paired its class-2 EBEs with class-1 typical values. They are now evaluated in each subject’s fitted class. Affects FOCE/FOCEI, SAEM, Bayes, and estimating IMP/IMPMAP mixture fits.Mixture covariance-step and diagnostics correctness (#984, follow-up to #983). Five fixes to the mixture SE/covariance and checkpoint paths: (1) the covariance step now reconverges its per-class EBEs at
cov_inner_tol, not the fit’sinner_tol, so a loose fit followed by a tightcov_inner_tolfor trustworthy SEs is honoured instead of silently building the Hessian on the loose EBEs; (2) when a per-classomega(k)/sigma(k)override collapses and makes the covariance base OFV non-finite, the diagnostic now inspects the worst-conditioned class Omega and reports the collapse instead of misattributing it to a model-evaluation overflow; (3) an explicitly chosen optimizer that cannot drive a mixture (built-in BFGS/L-BFGS, trust-region, Gauss-Newton) is still run under BOBYQA but now emits a warning rather than dropping the choice silently; (4) EBE non-convergence / fallback / hard-reject are now counted over every class that contributes to a subject’s marginal, not just its winning class, so a non-converged non-winning class is no longer hidden from the convergence guard;.fitrxrestore now orders thePMIX_*columns by class number rather than raw header position, so a bundle whose columns were reordered (e.g. alphabetically,PMIX_10beforePMIX_2) no longer silently swaps class probabilities.
Mixture posteriors survive a
.fitrxcheckpoint (#983). A saved-then-restored mixture fit now re-emits its per-subjectMIXEST/PMIX_1..Kcolumns: they round-trip through optional trailing columns onebes.csv(a non-mixture bundle is byte-identical to before). Previously the checkpoint dropped them, so a restored mixture fit’ssdtabsilently lost the mixture columns.Mixture-model correctness fixes (#980, follow-up to #977). The analytic outer gradient for a
MIXNUM-branched typical value (e.g. class-specific clearance) now resolves the correct class on every rayon worker, so a gradient optimizer (SLSQP/L-BFGS/MMA) is no longer misled by a class-swapped gradient; a covariate used only in a mixing expression is now registered as a required data column (it was silently read as 0, degrading covariate mixing to intercept-only);MIXNUMoutside a[mixture]model is rejected everywhere, not just in[individual_parameters]; and an evaluation-only mixture run (outer_maxiter = 0) now emits thePMIX_*/MIXESTcolumns like a converged fit.A design population is now rejected by
fit()instead of being fitted to its placeholders. The population returned byread_population_for_simulation()carries aNaNplaceholder for each not-yet-generated observation, and nothing checked observations for finiteness: with log-transformed-both-sides on natural-scale data the placeholder was floored to a finite, extreme value and the fit ran to completion on fabricated data with no warning. Such a population now fails up front withE_NONFINITE_DV, and propensity-score-matched simulation reports the real cause instead of a misleading “EBE did not converge” (#957).The default (
Auto→ analytic-gradient NLopt L-BFGS) optimizer no longer stalls at the initial estimates on its first step. From the identity initial Hessian the opening search direction is−∇, and on models where the scaled gradient is large at the start (e.g. warfarin FOCEI) that step overshot, the line search failed on evaluation 1, and the fit never left its initial θ — reporting standard errors for the initial point instead of the optimum. The identity-Hessian overshoot cap (previously SLSQP-only) now also tames the first L-BFGS gradient evaluation; later evaluations are left untouched so every later(s, y)curvature pair L-BFGS builds stays intact (only the first pair reflects the capped opening gradient). Combined with the analytic covariance Hessian (default-on), this re-enables the warfarin FOCEI covariance SE cross-checks against NONMEM and a steady-state oral fit smoke test (#960).sir = trueis no longer silently skipped when the covariance step fails (#972). A non-positive-definite FD Hessian used to leave a requested SIR run with no intervals and a warning pointing atcovariance = true, even though the|eigenvalue|-rectified fallback proposal for exactly that case had already been built — reaching it required the separatecovariance_fallback = siroption.sir = truenow arms that fallback itself and the fit reportscovariance_status: sir_fallback. When no proposal can be built at all, the warning now points at the covariance-step message that carries the actual cause, and a Bayesian fit is told that posterior credible intervals replace the Hessian-based covariance — instead of both being sent to an option that is already on.FitResult$vi$elbo_tightness_ratiosays whether the reported bound is usable, whichconvergedcannot: a stuck optimizer produces a flat objective and stable parameters just like a successful one. The data term’s excess over its value at the variational means is≈ d/2per subject whenqhas the posterior’s curvature, so the ratio of measured to expected is ~1 for a healthy fit; above 25 the fit warns and names the likely fix. A deep compartment model started badly scored 306.6 while reportingconverged: true; the same model withinitdeclared scored 0.54.FitResult$vi$elbo_tightness_ratiosays whether the reported bound is usable, whichconvergedcannot: a stuck optimizer produces a flat objective and stable parameters just like a successful one. The data term’s excess over its value at the variational means is≈ d/2per subject whenqhas the posterior’s curvature, so the ratio of measured to expected is ~1 for a healthy fit; above 25 the fit warns and names the likely fix. A deep compartment model started badly scored 306.6 while reportingconverged: true; the same model withinitdeclared scored 0.54.
0.3.0 - 2026-08-07
Added
Variational inference (
[fit_options] method = vi) — a third way to marginalize the random effects, alongside profiling them out at their mode (FOCE/Laplace) and sampling them (SAEM/IMP). VI fits a tractable posteriorq(η)per subject and optimizes its parameters jointly with θ/Ω/Σ by maximizing the evidence lower bound, with no inner loop. It suits models whose fixed-effects part is highly flexible (deep compartment models), cases where FOCE’s inner optimization is unstable, and anyone who wants a per-subject posterior covariance without paying for a Hessian — VI produces one as a by-product, reported onFitResult$vi. Following Janssen et al. (2024), with two departures: the KL term is taken in closed form rather than by Monte Carlo, and Ω is then updated by its exact maximizer rather than by gradient descent, which removes the Ω instability that paper reports. VI stops as soon as the objective has settled rather than always burningvi_itersiterations:vi_itersis a ceiling (default 25000), and settling is judged by testing whether the remaining drift is distinguishable from Monte-Carlo noise. Defaults were set against the NONMEM FOCEI warfarin reference (tests/nonmem/warfarin_imp.lst), which the previous defaults missed badly — θ and Ω are now within ~1% of NONMEM out of the box.vi_lrdefaults to 0.02 (was 0.05): at 0.05 the σ trajectory is non-monotone in iteration count. Note that the residual-error estimate is the one parameter limited by Monte-Carlo noise rather than by iterations.vi_mc_samplesdefaults to 8 (was 3, the value Janssen et al. use): at 3 draws warfarin’s σ² lands ~220% high, and an IOV recovery fit returnsTVCL0.85 against a true 1.0 while reportingconverged: true— both failing as a quiet stop at a worse point rather than as an obvious error. At 8 draws the IOV fit is indistinguishable from SAEM and FOCEI on every parameter. Raise it further when the residual error itself matters: σ keeps improving with more draws (~72% high at 8, ~39% at 16, ~20% at 32 on warfarin) long after θ and Ω have stopped moving. Tunable viavi_iters,vi_mc_samples,vi_lr,vi_family,vi_omega_update,vi_avg_last,vi_eta_grad,vi_klandvi_seed.vi_kl = mcselects the Monte-Carlo KL — unbiased but noisier, what a variational family with no closed-form KL requires, and a cross-check of the analytic one. Declared parameter bounds (theta TVCL(0.13, 0.001, 10.0)),block_omegastructure andFIXall behave as they do for the other estimators, and standard errors come from the ordinary covariance step run at the VI estimate, socovariance_status/se_thetaare populated as usual. Note that the ELBO is a lower bound, not a likelihood:ofvisNaNby default rather than being filled with a number that is not comparable to a FOCE/SAEM OFV. Setvi_final_ofv = laplace, or chainmethods = vi, impwithimp_eval_only = true, to evaluate a genuine marginal likelihood at the VI estimate. IOV is supported: the variational posterior covers the stacked[η, κ₁ … κ_K]jointly against a block-diagonal priorΩ ⊕ Ω_iov^{⊗K},Ω_iovgets its own closed-form maximizer pooled over every occasion of every subject, and per-occasionκmeans are reported onFitResult$vi$kappa_means. IOV composes with a time-varying clearance (the busulfan shape) from either theTIMEbuilt-in or a time-varying covariate — a pairing worth stating explicitly because the two effects are confusable: both make clearance differ between early and late records. ReadΩ_iovfrom a VI fit with one caveat: variational posteriors understate posterior variance andΩ_iovis a mean ofS + μμᵀover occasions, so it leans low (about 24% low on a simulated 60-subject, 4-occasion recovery whereθlanded within 2%); confirm it with SAEM or FOCEI if it is the parameter you care about. Non-Gaussian endpoints (TTE / categorical) are still unsupported and are refused with an actionable message.FitResult$vi$elbo_tightness_ratiosays whether the reported bound is usable, whichconvergedcannot: a stuck optimizer produces a flat objective and stable parameters just like a successful one. The data term’s excess over its value at the variational means is≈ d/2per subject whenqhas the posterior’s curvature, so the ratio of measured to expected is ~1 for a healthy fit; above 25 the fit warns and names the likely fix. A deep compartment model started badly scored 306.6 while reportingconverged: true; the same model withinitdeclared scored 0.54.New ODE steppers via
[fit_options] ode_method, on two independent axes. For stability: the linearly implicit Rosenbrock methodsrosenbrock23(order 2, aliasesros23/ode23s),rodas4(order 4) androdas5p(order 5). These are stable at the step size the tolerance needs on stiff systems (fast reversible binding / TMDD, Michaelis-Menten withKMfar below observed concentrations, long transit chains, QSP cascades), where the explicit method is stability-limited and either crawls or exhaustsode_max_steps. Analytic sensitivities run through the identical stepper, so switching does not move a model off the analytic-gradient path.rk45remains the default, and on thef64prediction path existing fits are bit-identical. The one deliberate exception is the generic (Dual2) analytic-sensitivity path: RK45’s stage combinations previously existed as two separate transcriptions that were not bit-identical to each other — thef64driver associated them asu + h·(b₁k₁ + b₂k₂)while the generic one accumulated((u + k₁·(h·b₁)) + k₂·(h·b₂)). Unifying them onto thef64association shifts the sensitivity path’s trajectory in the last bits, which the outer line search can amplify; it also means the gradient is now taken along exactly the trajectory the predictor reports, which it was not before. The stiff methods are full peers — each carries its own continuous extension, so every feature that reads ODE state between solver steps works with every method: non-Gaussian endpoints (TTE / categorical / CTMM), time-to-event simulation, adaptive / feedback dosing,[output]state columns and the analytic-sensitivity path. Internally the three integration drivers (dense saves, soft sampling, event-time root-finding) are now written once against aStepperabstraction rather than per method.For order:
vern7(Verner 7(6), 10 stages, explicit), for fits that are accuracy-limited rather than stability-limited — where step count scales astol^(−1/p)and a stiff method buys nothing. On the Savic transit NONMEM anchor atTOL=9-equivalent accuracy it takes 2.8× fewer steps thanrk45and is ~2.3× faster; at default tolerances it is ~1.4× slower, so it is a tight-tolerance tool rather than a blanket upgrade. Its in-step readouts interpolate with a cubic Hermite (3rd-order) rather than a matching continuous extension — documented under ODE Models.docs/model-file/ode-models.qmdnow carries a measured “which regime am I in?” table so the choice is made from solver statistics rather than guesswork.Pre-scheduled base regimen (loading dose) in adaptive-dosing simulation (#702).
simulate_adaptive()/simulate_adaptive_from_spec()now accept a base subject that already carries pre-scheduled doses — a loading / maintenance regimen, including a steady-state (SS=1) dose — instead of requiring a dose-free subject: the driver integrates the base regimen and the controller augments it at the decision schedule (the real TDM / MIPD workflow of starting on a fixed regimen and titrating on measured levels). The pre-scheduled doses reuse the same dose-resolution, break-timeline, and steady-state machinery aspredict()/simulate(), appear in the controller’s dosehistory, and are rebuilt alongside the realized ledger by the default-on frozen-replay verifier. A base dose sharing a time with a decision is observed pre-dose (the trough), symmetric with the controller’s own doses (#933); the TAFD anchor is the true global earliest dose even when a controller dose precedes the earliest base dose (#934); a base dose past a controllerStopstill lands (the base regimen is the patient’s standing prescription); and base doses into lagged / built-in input-rate (transit / zero-order absorption) compartments are supported (#935). Initially supported on constant-covariate, non-reset models only; time-varying-covariate (#930), IOV (#931), and system-reset (#932) base regimens are separate follow-ups (see below), never a silent mis-integration. Previously any base regimen was rejected outright.Pre-scheduled base regimen under a time-varying covariate in adaptive-dosing simulation (#930).
simulate_adaptive()/simulate_adaptive_from_spec()now accept a base subject carrying a plain bolus / infusion loading (or maintenance) regimen together with a time-varying covariate (declining renal function, aTIME-driven parameter, etc.): each base dose’s bioavailabilityFis resolved from its own covariate snapshot (the covariate active at the dose’s administration time — symmetric with a controller dose, whoseFis fixed at injection) and the dose is integrated under the per-segment PK, with the base-aware frozen-replay verifier carrying thatFso the run is checked bit-for-bit. Validated dose-for-dose against an independent mrgsolve renal-decline + loading-dose run (tests/reference/vanco_renal_loading_mrgsolve/). Lifts the #702base × time-varyingrestriction. Still a typed error under a time-varying covariate (a #930 follow-up): a steady-state, lagged, built-in input-rate, or modeled-RATEbase dose; and a base regimen combined with system resets (#932) remains rejected (base × IOV is lifted by #931, below).Pre-scheduled base regimen under inter-occasion variability (IOV) in adaptive-dosing simulation (#931).
simulate_adaptive()/simulate_adaptive_from_spec()now accept a base subject carrying a plain bolus / infusion loading (or maintenance) regimen together with inter-occasion variability (kappa): each base dose’s bioavailabilityF(and any other IOV-affected individual parameter) is resolved under the κ of the occasion — the decision window (#701) active at the dose’s administration time — symmetric with a controller dose, whoseFis fixed from the per-decision snapshot at injection, and the dose is integrated under the per-segment occasion PK with the base-aware frozen-replay verifier carrying thatFso the run is checked bit-for-bit. Validated against the independentpredict_iovengine on a reconstructed per-occasion κ and dose-for-dose against an mrgsolve loading-dose IOV run (tests/reference/vanco_iov_loading_mrgsolve/). Lifts the #702/#930base × IOVrestriction. Still a typed error under IOV (a #931 follow-up): a steady-state, lagged, built-in input-rate, or modeled-RATEbase dose; and a base regimen combined with system resets (#932) remains rejected.System resets (EVID=3) in adaptive-dosing simulation (#716).
simulate_adaptive()/simulate_adaptive_from_spec()now honor an EVID=3 reset carried by the base subject: the reactive driver zeros the compartments at the reset time (re-seeding anyinit(state)=expr) and turns off controller-issued infusions opened before it, exactly aspredict()/simulate()do, and the default-on frozen-replay verifier is reset-aware so the reset is validated each run. Previously a reset subject was rejected with a typed error. (Initially only a pure EVID=3 reset on a dose-free base subject; #932 below lifts that to a reset combined with a base regimen, including an EVID=4 reset+dose row.)Base regimen combined with a system reset (EVID=3 / EVID=4) in adaptive-dosing simulation (#932).
simulate_adaptive()/simulate_adaptive_from_spec()now accept a base subject that carries BOTH a pre-scheduled loading / maintenance regimen (#702) AND a system reset — composing #716’s reset machinery with #702’s base-dose seeding on the constant-covariate path. The reset zeros the compartments and turns off a base infusion opened before it (the reset floor, previously applied only to controller-issued infusions), and an EVID=4 reset+dose row’s dose now reaches the adaptive path — landing after its own reset (Reset < Dose) — instead of tripping the base-regimen guard. Validated by a degenerate oracle (base regimen + mid-horizon EVID=3 + controller reproducespredict()on the realized regimen carrying the same reset), a positive control (a base infusion spanning the reset is turned off, so the oracle is not vacuous), an EVID=4 oracle, and a steady-state base × reset oracle; the default-on frozen-replay verifier (reset- and base-aware) checks every run. Lifts the #702/#716base × resetrestriction. Base × reset UNDER a time-varying covariate (#930) or IOV (#931) remains a typed error — a #932 follow-up — never a silent mis-integration.Warning when a
combinederror model’s additive initial estimate is negligibly small (#847). A pre-fit check (W_ADDITIVE_INIT_SCALE) flags an additive SD start below 1% of the observation scale (median|DV|) on that endpoint. A near-zero additive start can trap the fit in a local minimum where the additive term collapses and the proportional term inflates — the worse basin on multimodal / over-parameterised problems (e.g. the cyclophosphamide parent→metabolite fit, where additive variance seeded at 0.5 traps at ≈0.94 instead of the global optimum ≈1878). The initial estimate is never changed — the warning advises a larger start.Simulation and prediction for binary (
[binary_model]) endpoints (#760).simulate()now draws a 0/1 outcome per binary observation record (previously it emitted no rows at all for a binary endpoint, silently and without an error), and the newpredict_categorical()returns the category probabilitiesP(Y = 0)/P(Y = 1)per record — a probability vector, which the scalarpredict()cannot represent.predict()remains the Gaussian predictor and returns no rows for a binary endpoint (seeChangedbelow for the one behavioural change it did gain). A[simulation]block driving a binary endpoint needstimes(binary outcomes are observed on the fixed grid), and--simulatestamps the drawn states onto the simulated population it then fits. Validated by a simulate → fit recovery (SSE) round trip on top of the existing Rglm/ NONMEM fit anchors.sdtab diagnostics for binary endpoints (#760).
{model}-sdtab.csvnow carries one row per binary observation record —DV(observed 0/1),PRED(P(Y=1)at η=0),IPRED(at the EBE η) andIWRES(standardized Pearson residual).CWRESis left blank, since the conditional weighted residual is defined through the Gaussian residual-variance model that a Bernoulli outcome does not have; columns undefined for a discrete record are likewise blank rather than sentinel values. Previously a binary endpoint produced no diagnostic rows at all.Analytic sensitivities for steady-state dosing into a built-in absorption compartment (#835). Fitting a model with an
SS=1dose into afirst_order/transit/igd/weibullabsorption compartment now uses exact analytic FOCEI gradients — the closed-form steady-state troughu_ss = (I − M)⁻¹·bcarried over dual numbers — instead of finite differences, so these fits run at full analytic speed. Steady-state into azero_orderwindow, and steady-state combined with an absorption lagtime, remain out of scope (rejected with a clear error).
Changed
ObsRecord::DiscreteStateandObsRecord::Countgained araw_timefield (#760). Discrete observations now carry the user’s TIME alongside the engine’s internal (occasion-shifted) clock, so reported rows join back to the input CSV the way Gaussian rows always have. Source-breaking for any external code that constructs or exhaustively destructures these variants.
Fixed
LTBS models scored a different objective under SAEM / IMP / VI than under FOCE. The fixed-η observation likelihood applied a
max(1e-12)positivity floor to the prediction — correct for a concentration, wrong forlog(concentration), which is legitimately negative for any concentration below one unit (ng/mL data, late samples, a high-clearance subject). Affected observations had their residual computed against ~0 instead of the true negative log-prediction, inflating it silently. FOCE/Laplace/AGQ never applied the floor and were unaffected, so this showed up as SAEM/IMP/VI disagreeing with FOCE on the same model. The floor now stands down under LTBS, where positivity is already enforced on the natural scale before the log is taken.Gradient-path-dependent FOCE/FOCEI objective on proportional-error models with a near-zero prediction (#958). The residual-variance floor (
MIN_VARIANCE, which clamps(f·σ)²so a vanishing prediction cannot produce a zero variance) was applied to the variance but not to its analytic derivatives∂R/∂f/∂²R/∂f². On a row whose prediction is driven to ~0 (e.g. drug fully eliminated at a late sample) the variance is clamped and locally constant inf, so its true derivatives are 0, but the accessors returned the raw2·f·σ²/2·σ². That made the analytic inner-EBE gradient disagree with the finite-difference objective it minimises, sogradient = auto(analytic) andgradient = fdcould converge to different empirical-Bayes modes and report different objective values (hence different ΔOFV/ΔAIC) for the same model at the same estimates. The variance-derivative accessors are now floor-aware, restoring analytic ↔︎ FD agreement.Standard errors for closed-form LTBS models under IOV (#486). The tighter inner-EBE tolerances that log-transform-both-sides models take for the fit and covariance steps (
LTBS_FIT_INNER_TOL/LTBS_COV_INNER_TOL, #665) were previously skipped whenever the model also carried[iov], on the grounds that LTBS × IOV ran its inner loop on finite differences. Now that this combination takes the analyticln(f)inner gradient, it carries the same tolerance sensitivity as any other closed-form LTBS model, so it takes the same tightened tolerances. Without this a fit would return quietly inflated standard errors — the same mechanism measured at roughly 65% on warfarin theta SEs — with point estimates unchanged, so nothing looked wrong. Setcov_inner_tol/inner_tolexplicitly to override.Parser-internal readout parameters no longer surface in warnings or diagnostics (#486). A
[scaling] y = ...readout that names a theta or eta directly is desugared into a hidden individual parameter. That hidden parameter could reach the “individual parameter(s) not mu-referenced” warning (advising the user to rewrite a parameter absent from their model file) and the eta/parameter metadata carried inFitResult, where it appeared under its internal__ferx_ro_*name with aCustomparameterisation. Both now filter it out, as the other consumers of that list already did. Present on[odes]models since #631.FD-fallback warning for an oversized readout quotes the right slot budget (#486). When a direct theta/eta readout cannot be given PK slots, the resulting parse warning reported the 128-slot ODE layout even for analytical models, whose spare region holds at most about 11 slots and typically 4–7 — overstating the user’s headroom by an order of magnitude and making the suggested remedy unactionable. It now quotes the pool the model actually draws from.
Adaptive-dosing
auc_targetexposure metric now integrates the pre-scheduled base regimen (#940). The signal-AUC pass behindauc_target_attainment(#391 S2.5b) previously scored each decision window on the controller’s realized doses only, dropping any pre-scheduled base regimen (#702 — a loading / maintenance dose), so on a base-regimen run the reported exposure — and henceauc_target_attainment— was biased low. Each window now integrates the base regimen (a loading dose before the window and a maintenance dose landing inside it) alongside the realized ledger, matchingpredict()/simulate()on the combined regimen. Constant-covariate subjects only, as before (time-varying-covariate / IOV / resetauc_targetruns remain rejected).FOCE inner EBE recovery no longer discards a near-optimal partial for a worse fallback (#378). When a subject’s individual objective is multimodal (e.g. a 3-cpt IV proportional model with six IIV etas conditioned on ten points), the closed-form inner BFGS can reach the conditional mode yet stall at a gradient norm just above
inner_tol; the exact-path recovery then restarted Nelder–Mead from η=0 and kept that (worse) basin, discarding the good BFGS partial. The recovery now keeps the lower-objective of {BFGS partial, NM} on non-FREM models — the guard the ODE inner path already used (#555) — so the closed-form and ODE forms converge to the same EBE. This removes the ODE↔︎analytical FOCE marginal OFV divergence (up to ~18 OFV units, platform-sensitive) that surfaced after theinner_toltightening in #330. FREM keeps its cold-restart re-centering unchanged.The reported inner-loop gradient method for
[odes]models is no longer mislabeled “finite differences” (#926, follow-up to #378).fit()already runs the exact analytic inner η-gradient for an in-scope ODE model (as it does for closed-form and IOV models), but the reportedgradient_method_inner— shown in the fit banner and{model}-fit.yaml— was derived from a closed-form-only predicate and always read “finite differences” for ODE, even while the outer-loop report correctly read “analytic”. It now reports the analytic method for an in-scope ODE model, matching the outer report and the route actually run. The report and the FD-fallback warning now share one predicate, so an in-scope ODE model whose every subject is genuinely finite-differenced (oral infusion into a built-in absorption compartment, or a rate-defined infusion underF ≠ 1) is still surfaced by the route banner and the warning rather than silently labeled analytic. Reporting only — no estimate, OFV, or diagnostic changes.Steady-state (
SS=1) dosing on[odes]models is now exact for a linear disposition, and warns instead of silently truncating on a nonlinear one (#914). An ordinary ODE bolus or infusion SS dose previously equilibrated by expanding a pulse train capped at 50 cycles, which under-reported the steady state by tens of percent for a slow disposition (the same truncation #908 removed from the analytical engine) — silently. A linear disposition now solves the exact periodic fixed point(I − M)⁻¹·bdirectly (value and analytic FOCE/FOCEI gradient), so slow PK is exact rather than low; a genuinely nonlinear RHS (e.g. Michaelis–Menten) still falls back to the capped iteration but now surfaces a non-convergence warning when the cap is reached without settling. Also faster: the linear case replaces ~50 RK45 cycles with a handful.predict()on a CTMM ([markov_model]) model now fails loud instead of silently returning no rows (#759). The equivalentsimulate()guard already existed and its message claimed to coverpredict(), but it only ran on the simulate path. State-occupancy predictionπ(t)is still to come (#820).A pure-TTE / pure-discrete population no longer panics on a
CMT ≥ 2dose (#905). Such a population is exempt from dose-compartment validation — itspkline is a placeholder and the TTE endpoint’sCMTis routinely ≥ 2 — but the predictor still rerouted the dose onto the event-driven walk, which aborted the process on the very dose the validator had declined to check. The analytical predictor now declines in lockstep with the exemption: a subject with no Gaussian observation returnsNaNwithout entering the walk (value and FOCE/FOCEI gradient paths alike), sopredict(),simulate()andfit()stay panic-free on such a population through both the endpoint-routed and model-blind loaders.
Performance
method = laplacegradients are far more accurate, and its objective is faster. The posterior Hessian that scales the quadrature grid is now taken analytically — it is the exact conditional∂²nll/∂b²the shared sensitivity sweep already assembles — instead of being rebuilt by a2d²+1per-subject finite-difference sweep. The grid-response term of the gradient likewise stopped re-sweeping the whole quadrature grid once per parameter: each node’s analytic∂nll/∂bis computed once and contracted against a node displacement differenced from a cheap exact linear-algebra map. Between them these removed a finite-difference of a finite-difference, and the gradient’s agreement with a reconverged finite difference of the objective improved by two to three orders of magnitude (warfarin:2.0e-5→6.5e-8relative atn_agq = 1,2.1e-7→1.8e-9atn_agq = 7). The Hessian change also speeds up every objective evaluation, and therefore the covariance step, which evaluates it~2·n_free²times. Laplace OFVs and standard errors shift very slightly, since the exact Hessian replaces an approximated one.method = foceiis unaffected: the two estimators still use different Hessians — that is what distinguishes them — and only the computation is now shared. Models outside the analytic sensitivity scope (TTE, categorical) keep the finite-difference path.Exact analytic covariance R-matrix for FOCE/FOCEI (#436). Standard errors on in-scope models are now the exact second derivative of the marginal the outer loop minimises, assembled from third-order sensitivities, instead of a second difference of the reconverged objective. The finite-difference stencil evaluated the objective
~2·n_free²times, each re-solving every subject’s inner loop, and amplified error as1/h²; the analytic route costs2N+1sensitivity evaluations per subject (N = n_theta + n_eta) with no inner re-solve beyond the single reconvergence at the converged point, and has nofd_hessian_stepto tune. Measured on warfarin: agreement with the reconverged finite difference to8.2e-5, at 3.0× the speed per subject. Out-of-scope models (ODE, LTBS, IOV, M3/BLOQ censoring, expression scaling, Form-C readouts,iiv_on_ruv, correlated or custom-magnitude residuals, time-varying covariates, FREM, covariate-selected error models, non-Gaussian endpoints,method = laplace/agq, andgradient = fd) keep the finite-difference covariance unchanged — it is correct for all of them — and a single out-of-scope subject drops the whole population back to it rather than mixing two approximations in one matrix. Setanalytic_cov_hessian = falsein[fit_options]to force finite differences.Exact analytic inner (EBE) gradients for log-transform-both-sides under inter-occasion variability (#486). Fitting a closed-form model that combines
log(DV) ~ additive(...)with[iov]now uses exact analytic sensitivities for the per-subject empirical-Bayes gradient, not finite differences. The population (outer) gradient has been analytic for this combination since #677; the inner loop had stayed on FD because the first-order IOV walk carried nolnjet, which made LTBS × IOV the last combination whose two loops differentiated by different means. Both now apply the sameg = ln(f)transform last, after the per-occasion output-scale quotient, so they differentiate the sameln(f/s)the objective scores — including combined with an expressionobs_scale. Estimates are unchanged; the EBE search reaches the same modes with one provider evaluation per inner step instead of~2·n_etapredictions. LTBS × IOV on[odes]models keeps the finite-difference fallback on both loops, as before.Exact analytic gradients for a closed-form Form C readout that references a θ or η directly (#486). An analytical (1-/2-/3-cpt) model whose
[scaling] y = <expr>readout names a theta or eta directly — e.g.y = central/V * TVSCALE + ETA_BASE, a baseline or scale factor that is not an[individual_parameters]entry — now takes the analytic sensitivity path on both loops instead of falling back to finite differences. The parser already desugared such a reference into a hidden individual parameter on the ODE path (#631); that pass now runs for the closed-form engine too, where the hidden parameter draws a free differentiable PK slot exactly like any other non-structural readout parameter (BMAX/KD, #650). Predictions are unchanged — only the gradient moves off finite differences. A model whose readout parameters overflow the slots its PK model leaves spare keeps the FD fallback, with the existing parse warning, rather than failing to parse.Honest
gradient_methodreporting when a readout outgrows the closed-form dispatch tables (#486). A closed-form model whose differentiated PK-slot count exceeds the width the sensitivity providers instantiate now reportsfdinstead ofanalytic. Previously the scope check verified that every slot was differentiable but never how many there were, so such a model was labelled analytic and then fell back to finite differences on every subject — the persisted label disagreed with the route actually taken. No estimates or standard errors change (those fits were already running on FD); only the reported and persisted gradient method does.Closed-form modified-release absorption (#860). A static multi-route absorption model (parallel / mixed pathways #505, per-route lag #856 — one
[odes]central compartment fed by a fraction-weighted superposition offirst_order/transit/igdinput-rate forcings into a linear 1-/2-compartment disposition) now evaluatespredict()/simulate()and finite-difference fits as a closed-form superposition of shifted single-route solutions, with no ODE integration, when the subject has no time-varying covariates, IOV, resets, or steady-state / infusion doses. The disposition is recognised from the compiled model by its behaviour (a probed constant, canonical 1-/2-cpt Jacobian), not by matching how it is written, and a non-linear disposition is declined and integrated as before. Predictions are unchanged to within the ODE solver tolerance — the closed form reduces to the same integrated twin — and any model outside this scope (includingweibullpathways) continues to integrate.Closed-form modified-release absorption now covers
zero_orderroutes (#860 Phase B). Azero_order(dur=...)pathway in a static multi-route model no longer falls back to ODE integration forpredict()/simulate()/ finite-difference fits — the box-car input rate has its own closed-form convolution against the linear 1-/2-cpt disposition, superposed exactly like the other pathway kinds. Predictions are unchanged to within solver tolerance.Closed-form modified-release absorption now accelerates the analytic FOCE/FOCEI gradient too (#860 Phase A6). Fitting a static multi-route model with the default analytic-sensitivity method now skips the ODE integration for both the value AND the gradient — previously only the value took the closed-form fast path, and the gradient still integrated. The disposition-recovery and superposition formulas are evaluated once, generically, over dual numbers instead of plain doubles, so there is no separate hand-derived gradient to drift out of sync. Gradients are unchanged to within solver tolerance — verified directly against the ODE-integrated analytic provider, not only against finite differences.
The closed-form steady-state equilibration for dosing into a built-in absorption compartment (#834) now actually takes effect. Its self-verification tolerance sat just below the solver’s own noise floor, so it silently fell back to the 50-cycle pulse-train iteration at every realistic
ode_reltol;predict()/simulate()on these models are correspondingly faster. Predictions are unchanged to within the ODE solver tolerance — the closed-form fixed point and the iteration converge to the same periodic trough (#835).Exact analytic FOCE/FOCEI gradients for per-route absorption lag (
fn(..., lag=L), #859). Fitting a model with a per-route absorption lag on afirst_order,zero_order,transit, origdinput-rate forcing now uses exact analytic sensitivities instead of finite differences — each route’s onset is a moving boundary carried by a rate-on saltation (and, forzero_order, a matching rate-off at the window end), so these fits run at full analytic speed. A per-route lag on aweibullforcing keeps the finite-difference fallback (its onset diverges for shape β < 1, so no closed-form saltation exists). The predicted values are unchanged — only the gradient moves off finite differences.Exact analytic FOCE/FOCEI gradients for per-route absorption lag under IOV (#877). A per-route absorption lag (
first_order/zero_order/transit/igd) combined with inter-occasion variability now uses exact analytic sensitivities too — the per-route onset saltation carries each occasion’sκthrough the same event-driven walk asη/θ, so a route-lag model with IOV fits at full analytic speed instead of finite differences. Aweibullper-route lag keeps the finite-difference fallback (as on the non-IOV path). Predictions are unchanged.
Changed
- SAEM prints its final OFV before the covariance step (#893). In a verbose run (the CLI default), the
SAEM completed. Final OFV = …line is now emitted before the covariance matrix is computed rather than after, so you can judge the fit and interrupt (Ctrl-C) before paying for the — often expensive — covariance step when the OFV already rules the run out. method = agqremoved; adaptive quadrature is now an argument, not a method (#251). Adaptive Gauss–Hermite quadrature is not a separate estimator — it is the single-point method (Laplace / FOCEI) evaluated on more nodes. So the method name now selects the Hessian anchor andn_agq(default 1) is the node count:method = laplace— the exact-Hessian anchor.n_agq = 1is the Laplace approximation (NONMEMLAPLACIAN);n_agq > 1is adaptive Gauss–Hermite quadrature (whatmethod = agqused to be).method = focei— the Gauss-Newton anchor.n_agq = 1is plain FOCEI (unchanged, bit-identical);n_agq > 1is a new Gauss-Newton-anchored quadrature that refines FOCEI toward the exact marginal (requires the analytic sensitivity scope).
n_agq → ∞; they differ only in node placement and, at one node, in whether½log|H|carries the exact curvature or the Gauss-Newton approximation. The oldmethod = agq(and itsgauss_hermite/adaptive_gaussian_quadraturealiases) is rejected by the parser with a message pointing tomethod = laplace+n_agq. This is a breaking change to the model file’s[fit_options];method = agqwas unreleased, so no released version — and no persisted.fitrxbundle — is affected.- Laplace / adaptive-GH quadrature uses a tighter default
inner_tol(1e-8, was the shared1e-5) (#251). Its analytic gradient assumes the EBE is exactly the posterior mode; a loose inner tolerance leftb̂off-mode from a poor start and could stall the outer optimizer. The tighter default converges robustly from realistic starts at negligible cost near the optimum. FOCE/FOCEI are unchanged (their Gauss-Newtonlog|H̃|is forgiving of a loose mode).
Added
- Infusion into an oral model’s peripheral compartment on the analytical engine (#375).
two_cpt_oral(CMT=3) andthree_cpt_oral(CMT=3/CMT=4) previously rejected a positiveRATE— and, before that, crashed on one — because the oral closed-form propagators had no peripheral forcing term where the IV ones did. They need no new closed form: nothing flows back into the depot, so a rate into a peripheral drives exactly the central/peripheral sub-system the IV model has, and the oral propagator superposes that same forced response onto its own homogeneous evolution. Combined with the bolus change below, every compartment of the six analytical disposition models now accepts both a bolus and an infusion, alone or together. (The transit and inverse-Gaussian absorption models are unchanged — they are dosed through the depot,CMT=1, only.) Validated against NONMEM 7.6.0ADVAN4/ADVAN12and — more tightly than NONMEM can express, since its own forced response carries ~2e-6 here — against a1e-12integration of the same system written out as explicit[odes], which agrees to ~1e-11 (tests/oral_peripheral_infusion.rs), including a peripheral infusion overlapping an oral depot dose.
Fixed
Analytic-gradient fits no longer report
converged = falseat a plateaued optimum (#751). The default analytic-gradient NLopt L-BFGS drives the OFV flat to ~8 significant figures and then returns a bareNLOPT_FAILURE— its line search can no longer beat an objective already at the noise floor. That terminal status was taken at face value, so a finished fit was mislabelled non-converged, the “Outer optimization did not converge” warning fired spuriously, and the covariance step ran flagged as off-stationary. A bareFailure/ForcedStopis now reclassified as converged only when the fit actually descended past its initial estimates, the OFV trace has plateaued (a flat tail of evals with no meaningful improvement), and the restored best point is self-consistent (a cold inner-loop restart reproduces the best-seen OFV). A genuine early stall — a first step that overshoots and leaves the fit pinned at its initial estimates, one still descending when it stopped, or an unreproducible warm-start “optimum” — keepsconverged = false, so real non-convergence is never masked.A dose into a compartment an
[odes]model does not declare is now an error, not a silent drop (#899). On the ODE engine every dose-application site was an unguardedif cmt_idx < n { … }with noelse, and nothing upstream rejected an out-of-rangeCMT: the data reader has no model, and the analytical dose-compartment check returned early for ODE models. A typo’dCMTtherefore produced a fit that converged and reported a finite OFV having ignored the dose entirely — no error, no warning. Such doses are now rejected up front, naming the subject, time, and the states the[odes]block declares;fit()returns an error andpredict()/simulate()fail with the same message, matching every other dose precondition. This is the ODE half of the analytical fix in #375.predict_survival()— the one member of thepredict/simulatefamily that was missing the guard — now enforces it too, so an unroutable dose can no longer silently change the exposure a joint PK-TTE hazard reads.CMT=0means the same thing on both engines (#899).CMT=0is NONMEM’s default dose compartment and resolves to compartment 1. The analytical engine has done this consistently since #375; the ODE engine did four different things with it depending on which driver a subject happened to take. The plain dataset path computed0 − 1on an unsigned index and underflowed — a debug build panicked with “attempt to subtract with overflow”, a release build wrapped tousize::MAX, failed the bounds check, and dropped the dose in silence. The event-driven driver (taken when a subject has a time-varying covariate, anEVID=3/4reset, or IOV) applied it to compartment 1. The steady-state equilibration bailed out and returned the single-dose curve. The remaining sites — the infusion channel list, the_with_statesdriver, the segment-boundary walk, and thesens/gradient twins — skipped the dose outright. So the same dataset could get three different answers, and a fit could differentiate a different dosing history than it predicted. Every site now resolvesCMT=0to compartment 1 — including compartment-indexed dose attributes: a dose writtenCMT=0now readsF1/ALAG1where before it missed the indexed lookup and silently fell back to the bareF/ALAGslot, which on a model declaring onlyF1means bioavailability defaulted to1.0and the dose was delivered at full amount. Predictions change for ODE datasets written withCMT=0, which previously got nothing (or crashed). This unification reaches every dose-compartment comparison, not just the state-vector index: aCMT=0dose into a built-inzero_orderabsorption compartment now opens its release window on the event-driven driver (a reset / time-varying covariate / IOV) — it previously matched neither the bolus nor the window there and delivered no mass at all; the steady-state gradient of aCMT=0dose into a built-in absorption compartment now equilibrates like the value path (it previously returned an un-accumulated trough, a silent value≠gradient FOCEI error); and the “unsupported steady-state combination” rejections (E_ABSORPTION_SS_ZERO_ORDER/E_ABSORPTION_SS_LAG) now fire forCMT=0instead of being bypassed. As on the analytical engine, an infusion withCMT=0is rejected rather than remapped: the default dose compartment is defined for a bolus but not for a zero-order input. This closes the cross-engine disagreement onSS+CMT=0noted under #375 below. Thecmt → state index(and its 1-based complement) is now a single named accessor (DoseEvent::cmt_idx/cmt_1based, #912) so the convention lives in one place rather than a dozen open-codedcmt - 1/cmt >= 1sites that drifted apart. The same unification reaches the analytical closed-form absorption guard: aCMT=0dose on aone_cpt_transit/two_cpt_transit/ inverse-Gaussian model is now accepted as the depot (compartment 1) and predicts identically toCMT=1, where it was previously rejected as a “non-depot compartment” — the closed form folds every dose through absorption regardless ofcmt, and its ODE twin resolvesCMT=0andCMT=1to the same forcing, so the two paths agree. A genuine non-depot dose (CMT>=2) is still rejected.Steady-state doses on the analytical event-driven path are now exact, not truncated (#908). A subject that cannot use dose superposition — because it has a time-varying covariate, an
EVID=3/4reset, IOV, or a dose into a non-default compartment — is served by the event-driven walk, which equilibrated anSS=1dose by iterating a pulse train capped at 50 cycles. The leftover was≈ exp(−50 · λ_slow · II), negligible for typical PK but not for slow drugs, and the early stop never fired there (it needs the per-cycle increment to be negligible, which is exactly what a slow mode prevents). The walk now solves the periodic steady state in closed form asu_ss = (I − M)⁻¹·b, the fixed point of the affine one-cycle map, using the same propagators it already runs. It agrees with the superposition closed forms to f64 precision (≤ 1e-12relative, bit-identical on several models) rather than to a tolerance, so the two representations of one dataset agree by construction. Measured error that this removes:model IIwas one_cpt_oral(CL 0.1, V 50 — t½ ≈ 350 h)12 3.0e-1 two_cpt_iv(Q 0.5, V2 500) — central12 1.0e-1 two_cpt_iv(Q 0.5, V2 500) — peripheral amount12 5.8e-1 three_cpt_iv(Q3 0.5, V3 400) — central12 8.2e-2 three_cpt_iv(Q3 0.5, V3 400) — third-compartment amount12 5.2e-1 three_cpt_iv(CL 5, V1 50, Q 3, V2 80, Q3 1, V3 120)24 2.3e-3 Compartment amounts — what
[derived]and per-compartment sdtab columns report — were affected considerably more than concentrations. If you have results involvingSS=1on this path from an earlier version, regenerate them. The gradient walk was converted in the same change, so FOCE/FOCEI differentiates the steady state it actually predicts; over dual numbers the same solve yields the exact implicit-function derivative. The truncated pulse train remains only where no periodic steady state exists (a zero disposition rate constant, e.g.CL = 0), and that case now raises the existing non-convergence warning instead of returning a silently truncated state. The ODE path’s ordinary bolus/infusion steady state still expands a pulse train (#914) — seedocs/model-file/steady-state.qmd. Validated against NONMEM 7.6.0 in the slow regime the fix targets — newss_slow_advan1/ss_slow_advan2anchors (CL = 0.1, t½ ≈ 347 h) that the pre-fix walk missed by 29 %.SAEM FREM /
iiv_on_ruvmixing diagnostics and safeguards (#895). The optimizer-tracemh_accept_rate(and the verbose banner) now reports the combined block + componentwise Metropolis-Hastings acceptance rate. Previously it showed only the block kernel, which reads a misleading 0% for FREM-scale Ω — the near-deterministic covariate ETAs reject every joint move — even when the componentwise sweep is mixing the chain fine. The block kernel now damps each FREM covariate coordinate bymin(1, √EPSCOV/√Ω_jj)so its joint acceptance recovers from 0% (non-FREM models are unaffected — the multiplier is exactly 1). SAEM also now warns when the combined post-burn-in acceptance stays below 1% (the sampler is not mixing, so Ω/σ are unreliable).SAEM
iiv_on_ruvσ × ω_RUV runaway fixed (#895, #904). Free-σiiv_on_ruvmodels — where the residual isY = f + EPS·exp(η_RUV)— could diverge under SAEM, with ω_RUV inflating toward ~49 and σ toward its ceiling (worst on FREM models with an extreme Ω-diagonal scale range). Root cause: η_RUV is a residual-scale random effect with no typical-value θ, so its mean was never absorbed and drifted along the σ × η_RUV degenerate direction, injecting a spurious mean² intoω_RUV = mean(η_RUV²). SAEM now re-centres η_RUV to zero mean each iteration, absorbing the shift into σ (which leaves every subject’s residual variance exactly unchanged) — the same device mu-referenced structural etas already use with their θ. Re-centring runs only when every RUV-scaled σ component is free; if any is FIXed (e.g. a fixed additive term), it is skipped and the growth caps below act as the backstop. On the 475-subject FREM reprex this converges to ω_RUV ≈ 0.28 / σ ≈ 0.20 from both a too-small and a too-large σ start (NONMEM: 0.28 / 0.18). Belt-and-braces σ and ω_RUV growth caps (each ≈ 20× the reference scale; the Ω cap is a correlation-preserving rescale that leaves FIXed off-diagonals untouched) remain as no-op backstops, warning if they ever bind. The reference scale is re-anchored to the data-informed value reached by the end of exploration and defers to a tighter user upper bound, so a fit started from a σ/ω_RUV guess far below the truth is not spuriously clamped or warned.Guarded multi-start inner EBE now covers weakly-identified random effects (#891). The per-subject EBE search (
inner_restarts, default1) previously re-seeded only subjects with system resets or time-varying covariates. It now also detects a weakly-identified coordinate — a random effect whose individual objective is flat (the data adds less curvature than the prior, i.e. high per-subject shrinkage) — and re-seeds just that coordinate on the cold start, so a distant lower posterior mode is no longer silently missed (e.g. a poorly-identifiedV1in a saturable-clearance fluconazole model). The flatness check is a two-point finite difference per coordinate; well-identified subjects are unchanged and pay only that probe. The guarded multi-start now also runs on an evaluation-only fit (maxiter = 0, NONMEMMAXEVAL=0), which previously seeded the inner EBE fromη = 0but was not recognised as a cold start, so the reported per-subject EBEs and objective now reflect the recovered modes.An analytic
[scaling]readout that references the oraldepotis rejected when the data dose a non-default compartment, instead of silently corrupting the objective (#375). The depot amount behind a Form C readout is reconstructed by dose superposition, which never reads the dose’sCMT— so a bolus writtenCMT=2on aone_cpt_oralmodel was reconstructed as if it had been absorbed through the depot, adding a phantom depot amount toPREDand therefore to the OFV. Measured ony = (central + depot)/V: OFV 188.37 where an explicit[odes]twin of the same model gives 1761.47 (the same model with the dose atCMT=1agrees with the twin exactly). This joins the existing reset-based rejection incheck_analytic_readout_support, with the same remedy — reference onlycentral, or use anode(...)model.A
[derived]integral overcompartments[i]no longer returns a wrong finite value for a subject dosing a non-default compartment (#375). The per-observation compartment columns correctly degrade toNaNfor those subjects, and the emitted warning says so — but the separate dense-grid reconstruction used byintegral(...)still used the older, narrower predicate and fell through to the compartment-blind superposition helper. In the same sdtab row,compartments[1]readNaNwhileintegral(compartments[1], 0→24)read 19.17 against a true 31.13. Both paths now use the same predicate.A zero-amount dose with an out-of-range
CMTis rejected instead of aborting the process (#375). The dose-compartment check skippedAMT=0rows entirely, on the reasoning that a zero bolus is a no-op — but both prediction walks bound-check the compartment before the amount is read, so such a row still panicked. It is reachable from ordinary NONMEM data: anEVID=4reset row written withAMT=0and a staleCMT.ferx checkreported the dataset clean andfit()then aborted. The range rule now applies to every dose regardless of amount; the zero-amount exemption is kept only for the infusion routing rule, whereduration = AMT/RATE = 0genuinely means nothing is delivered.A dose into a non-default compartment is now computed in that compartment on the analytical engine (#375). The closed-form dose-superposition path never read the dose’s
CMT: it chose the formula from the model, so it placed every bolus in compartment 1 (the depot of an oral model, central of an IV one) and every infusion into central, whatever the data said. A bolus into an IV model’s peripheral, or into an oral model’s central compartment (an IV loading dose against an oral maintenance model), was therefore computed in the wrong compartment — silently, with a finite OFV and no warning, and disagreeing with NONMEM by up to two orders of magnitude on a 3-compartment model. Which answer you got depended only on whether the subject happened to carry a time-varying covariate, anEVID=3/4reset, or IOV, since those route to the event-driven walk, which places doses correctly. Such doses now route to that walk on every dataset, so both paths agree and both match NONMEM. Validated against NONMEM 7.6.0ADVAN1/2/3/4/11/12(tests/nonmem_dose_compartment_anchor.rs). Per-compartment amounts in sdtab /[derived]are reported asNaNfor these subjects rather than wrong, with the existing warning extended to explain why; the rerouted doses themselves are computed exactly (that is what the NONMEM anchors pin).A steady-state dose with
CMT=0no longer loses its accumulation on the analytical event-driven path (#375).CMT=0is NONMEM’s “default dose compartment”, and every dose site resolves it to the model’s first compartment — except the event-driven walk’s steady-state equilibration, which bailed out early onCMT=0and returned an unequilibrated (all-zero) starting state. AnSS=1dose written withCMT=0therefore produced the single-dose curve instead of the accumulated steady state whenever the subject took that path (a time-varying covariate, anEVID=3/4reset, or IOV), while the same dataset without those features returned the correct steady state from the superposition path — a silent ~30 % under-prediction on a one-compartment example, with no warning. Both the value walk and the gradient walk now equilibrate the default compartment like any other, matching the closed form(D/V)·e^{−kt}/(1−e^{−k·II}). Predictions change only forSSdoses written withCMT=0. (The ODE engine bailed onSSwithCMT=0for a while longer, so an analytical model and its explicit[odes]twin disagreed on that combination; #899 above brought the ODE engine into line and closed that gap.)An infusion into a compartment the analytical model cannot deliver into is now an error, not a crash (#375). A positive
RATEinto a compartment outside the model’s infusable set — an oral model’s peripheral (CMT=3ontwo_cpt_oral), which theAddedentry above now makes work, or aCMTthe model does not have at all — used to abort the process from deep inside the event-driven prediction walk whenever the subject also had a time-varying covariate, anEVID=3/4reset, or IOV. Nothing validated a fixedRATEagainst the model’s topology: the data reader has no model, and the parse-time check only fires for a declaredD{cmt}/R{cmt}. Such doses are now rejected up front, naming the subject, time, and the compartments the model can infuse —fit()returns an error, andpredict()/simulate()fail with the same message, matching every other dose precondition. An out-of-range dose compartment (CMTpast the end of the model’s compartment list) is rejected the same way. This also removes a silent disagreement between the three analytical paths on the same dataset: the dose-superposition path used to route the infusion into the central compartment regardless ofCMT, and the gradient (sensitivity) walk used to drop it entirely — so a fit could have differentiated a different dosing history than it predicted. One behaviour change worth calling out: an infusion withCMT=0is now rejected. On an IV model that previously fitted, since superposition delivers into central, which is whatCMT=0means — so this is a deliberate tightening, not a bug fix:CMT=0is NONMEM’s default dose compartment, well defined for a bolus but not for a zero-order input, and leaving it implicit hid which compartment was being infused. Write the compartment explicitly. A bolus withCMT=0is unchanged (every path agrees it means compartment 1), as isSSwithCMT=0after the fix above.Analytic FOCEI sensitivities for IIV on an absorption lag feeding a
first_orderforcing (#880). Fixes to the rate-on onset of a built-infirst_order(Bateman) input-rate forcing whose arrival is a moving boundary — a compartment lagtime (ALAG1/LAGTIME) or a per-routelag=(#859): (1) the exact second-order sensitivity block (∂²f/∂η²) was wrong — disagreeing in sign and magnitude with finite differences — because the onset saltation’s curvature term dropped the forcing’s own time-variation at the onset (∂R_in/∂tad), non-zero only for such decaying kernels (constant infusion and zero-order windows were unaffected);- under a time-varying covariate crossing the onset, the onset jump read its absorption-rate constant and pathway fraction from the dose record’s covariate snapshot instead of the segment where the forcing actually turns on (NONMEM end-of-interval), giving a several-percent gradient error; and (3) an
n = 1(Erlang-2)transitkernel’s continuous-but-kinked onset dropped its curvature term. Both the shared-dose onset and the per-route onset are covered. Ordinary predictions and — outside the TV-covariate case — the FOCEI gradient were already correct; standard errors (the objective curvature) and the TV-covariate gradient now match finite differences.
- under a time-varying covariate crossing the onset, the onset jump read its absorption-rate constant and pathway fraction from the dose record’s covariate snapshot instead of the segment where the forcing actually turns on (NONMEM end-of-interval), giving a several-percent gradient error; and (3) an
Pre-flight flat-theta freeze no longer freezes an identifiable parameter with a coincidentally-tiny initial gradient (#826 follow-up). The #826 guard freezes a theta whose outer gradient is ~0 at the initial estimate, on the premise it is unmapped. But a near-zero initial gradient is not sufficient: e.g. a joint PK-TTE fit’s event-model hazard baseline
H0, evaluated atBETA = 0wherehazard = H0is momentarily flat in the coupling term (and whose ODE-path outer gradient is finite-differenced), tripped the guard and was frozen at its wrong initial value — biasing every other estimate (joint_pktteCL/V drifted out of tolerance). The guard now confirms each candidate with a perturbation probe: it only freezes a theta that leaves the reconverged objective exactly unchanged when moved (genuinely unmapped). Identifiable-but-flat-at-init thetas are left free, so the fit recovers them.Steady-state dosing into a built-in absorption compartment with a nonlinear disposition is now solved accurately (#867). For an
SS=1dose into afirst_order/transit/igd/weibullabsorption compartment on a nonlinear (e.g. Michaelis–Menten) disposition that accumulates heavily — elimination half-life far exceeding the dosing intervalII— the old 50-cycle pulse-train equilibration stopped well short of the true periodic steady state and silently returned a trough that was too low (38–79% low in pathological cases). The periodic steady state is now found by an Anderson-accelerated solve of the exact one-cycle fixed pointu = P(u)— a bounded handful of cycles across the clinical accumulation range — sopredict()/simulate()/fit()return the correct trough (and, for fits, analytic sensitivities via a dual Newton derivative correction). A non-convergence warning is raised (throughsimulate()/fit()) when no periodic steady state exists — mean input rate ≥ maximum elimination rate, a saturable drug dosed above its capacity — or, for an extreme model the bounded solve cannot converge, in place of a silently-biased trough. The linear case is exact via the closed formu_ss = (I − M)⁻¹·b(#835) and unchanged.FREM: the analytic gradients differentiated the wrong likelihood on covariate pseudo-observation rows (#251).
individual_nllscores aFREMTYPE > 0row against the predictiontheta[i] + eta[j]with the dedicated covariate errorEPSCOV— but the sensitivity provider returned the ordinary PK jet for those rows, and the gradient assemblies read the ordinary residual variance rather than theEPSCOVoverride that SAEM, importance sampling and the CWRES path all already applied. Both loops were affected:- the outer (population) gradient, so FOCE/FOCEI were minimising one objective while differentiating another; and
- the inner (EBE) gradient, so the empirical Bayes estimates themselves converged to the mode of the wrong likelihood.
The provider now rewrites both jets for pseudo-observation rows (
f = theta[i] + eta[j], unit first derivatives, zero second derivatives — the same{0, 1}Jacobian the FOCE H-matrix already stamped in), and both gradient assemblies use theEPSCOVvariance — consistently at every consumer, not only the two that motivated the fix: the outer jet override now also covers the ODE sensitivity provider (previously only the closed-form/TV-cov routes got it, so an ODE FREM subject still combined the raw PK jet with theEPSCOVvariance);method = foce’s Sheiner–BealR⁰and its σ-FD now take theEPSCOVoverride (previously only FOCEI’sscore_coredid); the FOCEIsigma_blockandsubject_eta_dxσ-FD loops now use it too (previously they FD’d the PK variance’s — zero — dependence onEPSCOV, sograd[EPSCOV]was identically zero underfoceiand a spurious term leaked into the other residual-error σ instead); and theiiv_on_ruvresidual-eta block and the custom-magnitude direct-θ channel now both skip FREM rows (a pseudo-observation’s likelihood has no η_ruv or magnitude dependence at all).On the warfarin FREM example the effect is large. A converged FOCEI fit goes from OFV 4900.6 to 211.0, and the importance-sampling marginal (
method = imp) from 19781.1 to 211.6 —impscores FREM rows correctly but centres its proposal on the inner-loop EBEs, so it inherited the wrong mode, the weights collapsed, and its estimate was meaningless.This is primarily a gradient fix, and most of the OFV gap is simply the fit landing somewhere else because the gradient that drove it there was wrong. One piece is not gradient-only, though:
find_ebe’s non-IOVh_matrix— the Jacobianfoce_subject_nlluses to build thelog|H̃|Laplace curvature term, which is part of the reported OFV — reuses the same provider choke point this fix corrects, and that Jacobian never received the FREM{0, 1}override before (only the IOV path and the FD-Jacobian fallback already had it). So a non-IOV FREM subject’s own curvature term was also wrong pre-fix, independently of the outer-gradient bug above. That the corrected FOCEI Laplace OFV (211.0) and the corrected 6000-sample IS marginal (211.6) — two independent approximations, and IS’s data term does not go throughh_matrixat all — now agree to under one unit is nonetheless a strong check that the new values are the right ones.Note the recovered covariate omegas barely move (118.71 → 118.67 for WT), because they are pinned by the pseudo-observations themselves. A FREM fit could therefore look entirely plausible on the one diagnostic a user would naturally check, and still be badly wrong.
Latent because FREM models are conventionally fit with
method = saem, which uses neither gradient — and the covariate-omega regression test runs SAEM. Fits underfocei,imp(or nowagq/laplace) were affected. SAEM fits are unchanged.AGQ /
laplace: the analytic outer gradient now covers the same models as FOCE/FOCEI (#251). Its score previously carried a Gaussian-only residual chain, so five endpoint families that FOCE/FOCEI already handled analytically — M3 censoring, IIV-on-RUV, a custom or time-varying residual magnitude, LTBS, and correlated residuals (block_sigma) — silently fell back to a finite-differenced score. They now share the same per-observation chain as FOCE/FOCEI and take the analytic route, so scope parity holds by construction rather than by a list that can drift. Under a custom residual magnitude this also corrects the θ gradient:mult(θ)makes the residual variance depend on θ directly, and that channel was not approximated before — it was missing entirely. FREM is included too — its pseudo-observation rows now ride the same analytic score as FOCE/FOCEI via the FREM fix above. TTE and categorical endpoints still take the finite-differenced score (neither re-solves the inner loop, so they remain fast) — they have no analytic chain in theDual2provider at all, not merely an AGQ-side gate.block_sigmais now accepted formethod = laplace(previously rejected atfit()even though the analytic score already carried acorr_diagbranch for it). Under IOV, the scope check now excludes non-Gaussian endpoints and bounds the custom-magnitude axis count the same way the non-IOV gate does — previously an IOV + TTE/categorical subject could pass the gate and silently score only the Ω prior, dropping the hazard term. A per-subject runtime decline inside the analytic score (an off-diagonalblock_sigmasubject, or magnitude × M3-censored) now falls back to the fixed-η FD score for just that subject, rather than dropping the whole population onto the2·n_free-inner-resolvereconverged_fd_gradientfallback.
Added
Per-route absorption lag — an optional
lag=argument on every input-rate function (#856) (first_order(ka=KA, lag=L),zero_order(dur=DUR, lag=L), …). Each parallel / mixed pathway can now switch on at its own delay — the immediate-release + delayed-release picture — instead of sharing one per-dose lagtime. The per-route lag is additive on top of any compartmentlagtime/ALAG(a route’s onset isdose + lag_cmt + lag_route);lag=0(or nolag) is bit-identical to an unlagged route. A model carrying a per-route lag is fit over finite differences (the analytic per-route onset saltation is a planned follow-up), likeweibull()+ lagtime; a negative lag warns (W_NEGATIVE_LAGTIME), a non-finite one is rejected. Steady-state (SS=1) dosing into a per-route-lagged absorption compartment is rejected (E_ABSORPTION_SS_LAG), consistent with a compartmentlagtime/ALAG(#719). New exampleexamples/per_route_lag_absorption.ferx; validated by reduction to the NONMEM-anchored compartment lag (tests/per_route_lag.rs) and by a direct NONMEMADVAN13 $DESanchor — ferx’s objective at NONMEM’s optimum matches#OBJV = −882.357to ~1e-6 (tests/per_route_lag_nonmem_anchor.rs).Infusion (
RATE>0) into a built-in absorption compartment (#719): an infusion into atransit()/igd()/weibull()/first_order()absorption input-rate compartment is now supported on the ODE path — previously rejected withE_ABSORPTION_RATE. The dose is treated as a zero-order source feeding the kernel: its mass is released at a constant rate over the infusion windowT, soR_inbecomes the convolution(F·amt/T)·[G(t) − G(t − T)]of the kernel with the rectangle (G= the kernel’s absorbed-fraction CDF), and the dose’s plain+rateinjection is suppressed. Predictions match NONMEM’s nativeADVAN2zero-order-into-depot behaviour and an explicit sub-dose train. Closed-formpk *_transit/*_igmodels with an infusion reroute to their ODE twin automatically. Sensitivities use a finite-difference fallback (at normal FOCEI speed — an infusion prediction needs no equilibration). Still rejected, with clear codes: an infusion into azero_order()window (E_ABSORPTION_RATE_ZERO_ORDER) and a steady-state infusion (E_ABSORPTION_SS_INFUSION).Steady-state (
SS=1) dosing into a built-in absorption compartment (#719): anSS=1dose into atransit()/igd()/weibull()/first_order()absorption input-rate compartment is now supported on the ODE path — previously rejected withE_ABSORPTION_SS. The dose is equilibrated through the absorption kernel (the periodic pulse train is superposed asR_in, and the disposition trough is the periodic steady state, a closed form(I − M)⁻¹·bfor a linear disposition), so predictions match an explicit long run-in of the same schedule and NONMEM’s exact analyticADVAN2steady state. Closed-formpk *_transit/*_igmodels with anSSdose reroute to their ODE twin automatically.fit()on an SS-absorption model converges; its sensitivities currently use a finite-difference fallback of the (exact) prediction, so large-dataset fits are slower than an analytic ODE model pending an analytic dual SS-equilibration. Still rejected, with clearer codes:SSinto azero_order()window (E_ABSORPTION_SS_ZERO_ORDER) andSScombined with an absorption lagtime (E_ABSORPTION_SS_LAG).Flat (zero-gradient) thetas are now frozen at start instead of killing the fit (#826). A pre-flight check computes the outer gradient at the initial estimate; any non-fixed theta whose gradient is ≈ 0 (a parameter that never reaches the objective — typically unmapped, or dropped from the structural / scaling model) is held fixed at its initial value and reported with a
flat_parameterwarning naming it, rather than leaving a zero search direction that made gradient-based NLopt returnFailureon the first evaluation and pin every parameter at its initial value. The remaining parameters now estimate normally.Exact (analytic) inner EBE gradient for CTMM (
[markov_model]) fits (#759). The transition likelihood−Σ log P(Δt)[s,s'],P = expm(Q·Δt), was finite-differenced end-to-end: every EBE step perturbed η, rebuiltQ, and redid a matrix exponential per observation gap. The intensities are now replayed over dual numbers with θ and η seeded, giving an exact∂Q/∂η, which is chained to∂P/∂Qthrough the Van Loan (1978) Fréchet derivative of the matrix exponential. The generator’s row-sum-zero constraint is inherited for free (differentiation is linear, so the derivative of a valid generator is a valid direction). Because only one entry ofPis read per gap, the adjoint form⟨C, L(A,E)⟩ = ⟨L(Aᵀ,C), E⟩lets a single Fréchet solve per gap serve every parameter at once — so the exact gradient is cheaper than the finite differences it replaces, not just more accurate. A drug-driven (time-inhomogeneous)Q(t)keeps the FD path: its likelihood is an occupancy ODE, not anexpm, so the identity does not apply. The FOCEI Laplace½log|H̃|term and the outer θ-gradient are still FD.AGQ and
laplacenow support inter-occasion variability ([iov]) (#251). Under IOV the integral runs over the stacked random-effect vectorb = [η, κ₁ … κ_K], whose prior is the block-diagonalΩ ⊕ Ω_iov^⊕K— which is exactly what ferx’s IOV likelihood already scores, so every AGQ formula carries over withdthe stacked dimension. IOV is a change of dimension, not of method. The tensor grid is thereforen_agq^(n_eta + K·n_kappa)and grows with the occasion countK, so the 100 000-node cap is now enforced against the stacked dimension once the data is read (the error namesK).method = laplaceis always tractable under IOV — its grid is a single point regardless ofd. Previously both methods rejected[iov]models outright.method = laplace— the Laplace approximation as a first-class estimator (#251). Aliaslaplacian. This is NONMEM’s$EST METHOD=1 LAPLACIAN: the Laplace approximation built from the exact Hessian of the conditional likelihood. It is not the same estimator asfocei, which builds its Gaussian from the Gauss-Newton HessianCᵀC + Ω⁻¹(dropping∂²f/∂η²) and therefore reports a different OFV;laplacecarries the curvature of the η-dependent residual variance that the Gauss-Newton form discards, which is why it reproduces NONMEM’s LAPLACIAN to six significant figures on warfarin. At the defaultn_agq = 1it is a single node, no grid — the cheapest configuration, and on warfarin it converges faster than FOCEI (0.23 s vs 0.60 s);n_agq > 1turns it into adaptive Gauss–Hermite quadrature over the same objective. See the AGQ docs page.Exact (analytic) FOCE/FOCEI gradients for lagtime models with IOV, time-varying covariates, or
TIME(#486): a closed-form model carrying anALAG/LAGTIMEused to fall back to finite differences the moment the subject also had IOV, a time-varying covariate, or aTIME-dependent parameter — which covers a large share of everyday oral popPK models. Those fits now use the exact analytic gradient on both loops: they are faster (FD costs one extra objective evaluation per parameter) and no longer inherit the finite-difference step’s accuracy loss. Estimates are unchanged within convergence tolerance. Steady-state doses combined with a lagtime still use finite differences.Exact (analytic) gradients for steady-state dosing combined with an EVID 3/4 reset (#486) on the closed-form engine — previously finite differences (the ODE engine already had it).
Analytic gradients for
[scaling] y = <expr>readouts that reference many parameters (#486): a Form-C readout whose individual parameters spilled past the eight structural PK slots (e.g. a sigmoid-Emax readout on a 3-compartment oral model) fell back to finite differences on the time-varying-covariate path; it is now analytic.Wider models keep the analytic gradient (#486): the monomorphisation caps that decide when a model is too wide for the exact gradient were raised from 16 to 24
θ + η(the ODE and output-scaling paths). A mid-sized covariate model (5 structural θ + 6 covariate-effect θ + 5 η) sat at exactly the old limit, so adding one more covariate silently dropped the whole fit to finite differences. The closed-form event walk’s cap is now coupled to the ODE one, closing a pre-existing gap where a 17–24-axis model took an analytic outer gradient against a finite-difference inner one.Guarded multi-start inner EBE, on by default (#830,
inner_restarts, default1): escapes a multimodal individual objective, where a single warm-started inner optimizer can settle in the wrong basin and inflate a subject’s objective. The classic case is saturable protein binding (a high-volume/low-concentration and a low-volume/high-concentration fit both explain a total-concentration profile). Subjects on the event-driven path (system resets or time-varying covariates) now re-solve the EBE on a cold start from Ω-scaled seeds per random effect and keep the lowest-objective mode; the outer warm start carries it forward, so the scan runs once per subject per fit (≈0 % overhead). A seed that reconverges to the same mode is not accepted, so unimodal subjects are unchanged; only a genuinely trapped subject moves — to the deeper, correct basin. On the fluconazole free/total binding model this recovers the NONMEM fit (OFV 734.67 vs NONMEM 734.64, versus 749.3 before). Setinner_restarts = 0to restore the previous single-start behaviour. See Fit options.L2data column for correlated observation units (#827): the reader now recognizes NONMEM’s level-2 grouping item. Observation rows sharing anL2value within a subject are paired into one correlated unit for ablock_sigmaresidual (e.g. the total + unbound rows of one blood draw), giving the user explicit control over which records the cross covariance couples instead of relying on co-temporal row order. See Data format.Two
block_sigma/L2data diagnostics (#830), reported byfit()andferx check:W_BLOCK_SIGMA_L2_ORDERwhen a correlated residual has a co-temporal group that can pair more than one way and noL2column is present (the fallback pairs in CSV row order, so reordering rows changes the fit — add anL2column); andW_L2_UNUSEDwhen the data has anL2column but the model declares noblock_sigmacorrelation (the reserved column is inert and, if it was meant as a covariate, was silently dropped).Continuous-time Markov model (CTMM) endpoint (#759): a new
[markov_model]block fits a discrete-state Markov process observed at irregular times. Declare states bound to their integer DV code (states = [awake=0, asleep=1]) and onetransition A -> B = <intensity>line per allowed transition; ferx fills the generator’s row-sum-zero diagonal and scores each consecutive observation pair with the exact transition matrixP(Δt) = expm(Q·Δt). Time-homogeneous generators fit with FOCEI (default), SAEM, or IMP; intensities may carry covariates and between-subject random effects. An intensity may also depend on a model state — a drug concentration (central / V) or a PD response — making the generator time-inhomogeneousQ(t) = f(state(t))(#817); ferx then integrates the occupancy ODEdP/dτ = P·Q(state(t))(forward Kolmogorov) over each observation gap instead of the closed-form matrix exponential (requires an ODE model). Requires themarkovcargo feature. See the Markov models and CTMM estimation pages. (mCTMM/DTMM and CTMM simulation are planned follow-ups.)Adaptive Gaussian quadrature (
method = laplacewithn_agq > 1) (#251): generalises Laplace. Instead of approximating each subject’s marginal likelihood with a single Gaussian at the empirical-Bayes mode, it evaluates the exact conditional likelihood on a Gauss-Hermite grid laid around that mode ([fit_options] n_agq, default 1 node per random effect).n_agq = 1reproduces the Laplace approximation identically — it matches NONMEM$EST METHOD=1 LAPLACIANto five significant figures on warfarin. Because it makes no Gaussian-residual assumption it handles non-Gaussian endpoints (TTE, categorical) that FOCE/FOCEI structurally cannot, and unlike SAEM/IMP its objective is deterministic — the OFV is bit-identical run to run. AGQ carries an analytic outer gradient (the posterior-weighted score over the quadrature nodes), so a convergedn_agq = 3warfarin fit takes 0.39 s against FOCEI’s 0.29 s and NONMEM LAPLACIAN’s 1.21 s, and reproduces NONMEM’s estimates to 4–5 significant figures on every parameter. Cost isn_agq ^ n_etaper subject per iteration, so it suits models with few random effects; grids over 100 000 nodes, out-of-range node counts, and IOV models are rejected at check time. See the AGQ docs page.Restart of an interrupted run from a checkpoint (#755): a fit now periodically saves a small
{model}.tmpresume point (throttled to[fit_options] checkpoint_interval_secs, default 300 s, so short runs write nothing). If the process is stopped, the next run of the same model + data resumes from the last saved estimates instead of starting over, and the file is deleted on successful completion. A model/data hash check invalidates a stale checkpoint (the run then starts fresh). Pass the CLI flag--cleanto force a fresh start, or setcheckpoint = falseto disable saving. Works across all estimation methods (resume is a coarse warm-restart from the saved population estimates, not a bit-exact optimizer-state resume).Binary / logistic endpoint (
[binary_model], #760): mixed-effects logistic regression as a first-class non-Gaussian endpoint (Phase 4, Track C). Declare a binary observation compartment withcmtand alogitlinear predictor over θ/η/covariates (and the per-recordTIMEbuiltin);DV ∈ {0,1}on that CMT is scored with the Bernoulli likelihood−Σ[y·log p + (1−y)·log(1−p)],p = logit⁻¹(lp). Works with FOCEI (FD-Laplace), SAEM, and IMP, and supports the fixed-effects (n_eta = 0) special case — ordinary logistic regression. Validated exactly against base-Rglm(DV ~ X + TIME, family = binomial)and NONMEMF_FLAG=1: ferx reproduces the glm/NONMEM coefficients and its OFV equals the glm deviance / NONMEM −2 log L to 5 decimals (seeexamples/binary_logistic.ferx,docs/estimation/categorical.qmd,tests/reference/binary_logistic/). A non-Bernoulli code (DV ≥ 2) on a binary CMT is rejected fail-loud. Ordinal / Poisson / negative binomial are planned follow-up slices.IOV (inter-occasion variability) for the analytic absorption models (#719):
pk one_cpt_transit/two_cpt_transit/one_cpt_ig/two_cpt_ignow accept IOV (kappaparameters with aniov_columnoriov_occasionrule). A subject carrying IOV is transparently rerouted to the model’s exacttransit()/igd()ODE twin, which integrates the cross-occasion dose carryover the closed-form superposition cannot express (#104) — so fits and predictions are correct on both the analytic and ODE engines, for IOV specified either via a datasetOCCcolumn or a model-codeiov_occasionrule. Previously rejected with a clear error; twin-less forms (a user[odes]/[scaling]/[initial_conditions]block) still are. Validated by exact analytic≡ODE-transit()/igd()-forcing equivalence at non-zero per-occasion κ, including multiple-dose regimens (tests/transit_analytic_equivalence.rs,tests/ig_analytic_equivalence.rs).Left truncation (delayed entry) for clock-reset RTTE (#740): repeated time-to-event models with
clock = resetnow accept aTENTRY > 0entry time instead of rejecting it. The first inter-event sojourn is conditioned on survival to entry in absolute time (H(t₁) − H(TENTRY)), then the renewal clock takes over for later gaps — the same delayed-entry convention already used for single-event and clock-forward RTTE, soTENTRYmeans one thing across every survival endpoint (condition on survival past entry, never restart the clock at entry). Assumes the time origint = 0is the renewal origin of the first sojourn; coincides with the pure renewal form (and with clock-forward) for a memoryless exponential hazard.Left truncation for RTTE simulation (#740):
simulate()now acceptsTENTRY > 0for repeated events on both clocks, drawing the stream on the time origin conditioned on survival to entry (clock-forward seeds its conditioning clock atTENTRY; clock-reset draws its first sojourn conditional on entry, then renews from 0) — the simulate dual of the fit-side conditioning, so a simulated left-truncated stream refits under the same convention.TENTRY = 0stays byte-identical to the non-truncated draw.Analytic inverse-Gaussian (IG) absorption closed form (#790): the Freijer & Post inverse-Gaussian absorption model is now available as the analytic structural models
pk one_cpt_ig(cl, v, mat, cv2)andpk two_cpt_ig(cl, v1, q, v2, mat, cv2)— the exponential-tilting closed form of the sameigd(mat, cv2)density the ODE path uses, giving exactDual2FOCE/FOCEI gradients that are independent of ODE-solver tolerance, and a uniformpk-line interface consistent with the analytic transit models. Supports absorptionlagtime, bioavailabilityf, IIV, and time-varying covariates (auto-rerouted to the ODEigd()twin per subject); IOV / steady-state / infusion / non-depot doses are rejected with a clear error, as for the transit closed form. Outside the tilting convergence domain (ke ≥ 1/(2·MAT·CV²)) a plain model transparently falls back to its ODE twin. Note on performance: unlike the analytic transit models (whose stiff-ish ODE makes the closed form ~28–31× faster), IG’sigd()ODE is non-stiff and cheap, so the closed form is not a speed win — it is ~2× slower per objective evaluation than theigd()forcing; use the ODEigd()forcing if raw speed matters, and this closed form when you want exact tolerance-free gradients or the uniform interface. Validated against the numericaligd()ODE (tests/ig_analytic_equivalence.rs, 1-/2-cpt) and directly against NONMEM$DESon an in-domain IG-truth dataset (tests/ig_analytic_nonmem_anchor.rs: ferx −1303.528 vs NONMEM −1303.639). See examples/one_cpt_ig.ferx.High parameter-correlation warning (#781): a fit now emits a
high_correlationwarning when a THETA (fixed-effect) pair’s estimate correlation (from the covariance matrix) has |r| ≥ 0.95 — a sign of over-parameterization / non-identifiability that names the specific culprits (complementing the aggregatecondition_number). Emitted typed at source withdetailslisting each{parameter_a, parameter_b, correlation}. See the warnings documentation.Inflated-RSE warning (#781): a fit now emits an
inflated_rsewarning when a free THETA’s relative standard error (100 · se / |estimate|) exceeds ~50% — an imprecisely estimated parameter, often a sign of over-parameterization. Emitted typed at source withdetailslisting each{parameter, estimate, se, rse_pct}. Requires a successful covariance step (no SEs → no warning). See the warnings documentation.simulate_with_options_diagsurfaces per-subject simulation diagnostics (#762, #763): a new entry point returningSimulationOutput { results, warnings }— the simulation analogue ofFitResult.warnings. It reports subjects handled specially during a run (a degenerate hazard draw, an over-large recurrent stream) instead of letting them look like ordinary censoring.simulate_with_optionsis unchanged (a thin wrapper returning just the rows), andferx <model> --simulatenow echoes these warnings alongside the fit warnings — including in the structuredwarnings_structured/ JSON output, under a new typedsimulationWarningCode.Boundary-estimate warning (#781): a fit now emits a
boundary_estimatewarning when a free THETA estimate is pinned to an optimizer bound (evaluated in the optimizer’s packed/log space) — a sign of non-identifiability or a too-tight bound. The structured warning carriesdetailslisting each{parameter, estimate, bound, side}, and is emitted typed at its source (the first warning to use the native at-source path rather than string classification). See the warnings documentation.Finer covariance-step warning codes (#781): the overloaded
covariance_stepwarning code is split by severity intocovariance_failed(Critical — no standard errors),covariance_regularized(Warning — SEs degraded but present), andcovariance_step(Info — cost notes), so an agent can branch on the outcome. The failure/regularized codes carrydetailswithcondition_number,min_eigenvalue, andn_negative_eigenvalueswhen those were computed. See the warnings documentation.High ETA-shrinkage warning (#781): a fit now emits an
eta_shrinkagewarning when any random-effect (ETA) shrinkage exceeds ~30% (the Savic & Karlsson rule of thumb) — the data poorly inform that IIV, so EBE-based diagnostics for it are unreliable and removing the IIV is often warranted. The structured warning carriesdetailslisting the affected ETAs and their shrinkage percent. See the warnings documentation.Warning
detailspayloads for numeric diagnostics (#781): structured warnings fordw_autocorrelation,eps_shrinkage, andcondition_numbernow carry adetailsobject with the value behind the message (e.g.{"durbin_watson": 1.20, "iwres_lag1_autocorr": 0.40}), sourced from the fit’s typed fields so an agent reads the number directly instead of parsing prose. First increment of the at-source warning work; other codes omitdetailsuntil converted. See the warnings documentation.Typed warning taxonomy (#778): structured warnings (
FitResult.warnings_structured, surfaced in the JSON output) now carry a typedWarningCodeinstead of a free-text category string — a stable, exhaustive vocabulary an agent or the R wrapper can branch on. Each code serializes as a fixed snake_case token (unchanged from the previous category strings, so JSON consumers are unaffected), and each entry gains an optionaldetailspayload for machine-readable numbers behind the message. See the warnings documentation.Machine-readable JSON fit output (#777):
ferx <model> --data <csv> --output-format json(orboth) writes{model}-fit.json— the complete fit result (every estimate, standard error, diagnostic, per-subject record, and provenance field), not the curated human YAML. It carries a top-levelschema_versionso programmatic/agent consumers can pin, matrices serialize as{rows, cols, data}(row-major) and vectors as flat arrays, and non-finite floats become JSONnull.--output-format yaml(the default) is unchanged. Library callers can get the same payload in-process viaFitResult::to_json_value(). See the output documentation.Experimental
markovfeature — CTMM matrix-exponential foundation (#759): a new default-offmarkovcargo feature adds the numerical core for continuous-time Markov models — transition probabilitiesP(Δt) = expm(Q·Δt)(nalgebra’s scaling-and-squaring Padé) with exact Van Loan (1978) parameter gradients, plus a guarded individual CTMM likelihood term. This is a library-internal primitive with no model-file syntax yet; wiring it into estimation is a later phase (seeplans/tte-survival-markov.md).Declare IOV occasions in the model (#756): a new
iov_occasionkey in[fit_options]derives the occasion partition from each subject’s timeline instead of requiring a precomputed dataset column.iov_occasion = dosestarts a new occasion at each administration;iov_occasion = time(24, 48)splits by time-window breakpoints. When bothiov_occasionandiov_columnare set, the model-side rule wins (with a warning). Both rules bucket observations and doses on the same internal event clock (correct for reset-stacked crossover subjects), co-timed doses share one occasion, a degenerate single-occasion partition errors instead of silently under-identifying kappa, and the derivedOCCcolumn is written tosdtab(#757). Atime(...)breakpoint list must contain only interior boundaries (the first occasion starts at-∞, the last runs to+∞); a leading0(thec(0, 24, 48)habit) is rejected with a message pointing at the correcttime(24, 48)form. See the IOV documentation.ferx summarycompares multiple runs (#749): pass two or more.fitrxbundles (ferx summary run1.fitrx run2.fitrx run3.fitrx) to print a Markdown table comparing them side by side — method, convergence, OFV/AIC/BIC, ΔOFV, runtime, subject/observation/parameter counts, and THETA/OMEGA/SIGMA estimates. A single bundle still prints the detailedpsn::sumo-style report.Standalone covariance step (#738):
run_covariance()runs the FD-Hessian covariance step against an existing fit without re-fitting, mirroringrun_sir(). It re-reads the model/data from the fit’s recorded paths (with SHA-256 integrity checks, refusing stale inputs) or accepts caller-suppliedmodel/population, then returns a fit withcovariance_matrix, standard errors,covariance_status, and condition-number diagnostics refreshed. It reusesfit()’s inline covariance step at the converged point, so the result matches an inlinecovariance = truefit up to finite-difference noise. A covariance step that runs but fails (non-PD / unusable FD Hessian) is non-fatal — the returned fit reportscovariance_status = Failedwith a diagnostic warning.Per-parameter estimates + gradients in the optimizer trace (#640): when
optimizer_trace = true, each CSV row now also records the full parameter vector (val:<name>columns, natural/reporting scale) for every method and the full gradient vector (grad:<name>columns, optimizer-scaled space) for the gradient methods (FOCE/FOCEI/GN;NAfor SAEM). Columns are named after the declared parameters (TVCL,ETA_CL,ETA_V~ETA_CL,PROP_ERR) withTHETA1/OMEGA(2,1)/SIGMA(1)fallbacks, and reconstructgrad_normassqrt(sum(grad:<name>^2)). This powers a per-parameter convergence view in the ferx-r trace UI.[data]block column renaming (#730, #742): rename any dataset header to any new name withnew-name = actualentries (e.g.TIME = TAFD,DV = CONC), the ferx equivalent of NONMEM’s$INPUT TIME=TAFD. Targets are not limited to canonical roles — arbitrary columns and covariates can be renamed too, and a column can be renamed aside to free its name for another (e.g.ODV = dvthenDV = lndv). Header matching is case-insensitive, renamed headers are excluded from covariate auto-detection under their old name, and typos (absent header, duplicate target, or a target colliding with a surviving column) fail loudly. See Data → Column mapping.Adaptive (feedback) dosing now supports time-varying covariates (#700): the reactive driver recomputes each subject’s PK per event/segment from the covariate active in that segment (the same NONMEM end-of-interval convention
predict()/simulate()use), instead of freezing it at thet=0snapshot. A covariate that changes over the horizon — e.g. declining renal function driving clearance under TDM titration — now correctly drives the predictions, the monitored signal, and every dose decision, and the frozen-replay verifier validates the per-event bookkeeping. Models whose PK reads theTIMEbuilt-in are covered too (previously also silently frozen atTIME=0). Theauc_target_attainmentmetric is not yet available for time-varying-covariate subjects and is rejected with a typed error rather than reported from a frozen snapshot.Adaptive (feedback) dosing now supports inter-occasion variability (IOV) (#701): the reactive driver draws a fresh occasion
kappaper decision window (occasion = decision index) and threads it through the per-event PK — instead of silently holding every kappa at zero — so occasion-to-occasion shifts in CL/V correctly drive the predictions, the monitored signal, and every reactive dose decision, and the frozen-replay verifier validates the per-occasion bookkeeping. Composes with the #700 time-varying-covariate path (a model with both is per-event correct in each).kappais drawn on a dedicated per-(subject, replicate) substream, so a non-IOV run is byte-identical to before and enabling aDvmonitor never shifts the draws. Theauc_target_attainmentmetric is not yet available for IOV subjects and is rejected with a typed error rather than reported from a κ-frozen snapshot. A non-ascending or duplicated adaptivedecision_timesschedule (programmaticsimulate_adaptive) is now rejected with a typed error, matching the declarative[adaptive_dosing]path — an out-of-order schedule would otherwise mis-map a record to the wrong occasion.estimation:block in{model}-fit.yamlnow splits wall time by stage (#713): a{method}_wall_time_secsentry (e.g.focei_wall_time_secs,imp_wall_time_secs) is reported for each stage ofmethod/methods, plus acovariance_wall_time_secsfor the post-estimation FD-Hessian / SIR-fallback step, alongside the existingwall_time_secstotal. Also carried onFitResult.method_wall_times_secs/FitResult.covariance_wall_time_secsand round-trips through.fitrxbundles.Repeated time-to-event (RTTE) models (Phase 3):
[event_model]acceptstype = rttefor endpoints with multiple events per subject, withclock = forward(Andersen–Gill total time, the default) orclock = reset(gap time / renewal). Clock-forward integrates the cumulative hazard once across each subject’s records (Σ_k log h(t_k) − H(T)); clock-reset restarts the hazard clock at each event (Σ_k log h(Δ_k) − Σ_k H(Δ_k)over inter-event gaps). Both use the analytic hazard families. Because Laplace/FOCEI can severely underestimate the frailty variance ω² for RTTE at low event rates (Karlsson et al. 2009), fitting RTTE under a Laplace-based method with a frailty now emits a warning recommendingmethod = saemormethod = imp(fired only for a frailty model —n_eta > 0— whose chain’s final estimating stage is Laplace-based, so a warm-start like[focei, saem]does not false-warn).simulate()draws a recurrent event stream per subject up to the administrative[simulation] horizon— clock-forward via conditional inverse-CDF draws (each event conditioned on survival past the previous), clock-reset via fresh gap draws — for the analytic hazard families. Unsupported configurations (interval-censored, out-of-order, non-finite, or — for simulation — left-truncated, multiple RTTE causes, an RTTE cause mixed with a competing single-event cause, or EVID=3/4 resets) are rejected with a clear error rather than silently producing a wrong answer;predict_survival()reports first-event survival for RTTE (use itscum_hazardfield for the recurrentE[N(t)]).two_cpt_transitnow supports time-varying covariates andTIME-dependent parameters (#724): a 2-cpt transit model whose disposition switches mid-profile is transparently routed to an exact ODEtransit()twin (central+periph), exactly asone_cpt_transitalready was — instead of being rejected. This removes the 1-cpt/2-cpt asymmetry. IOV, steady-state, infusion, and reset doses on a transit closed form remain rejected (use an explicit ODEtransit()model for those). A non-depot (CMT≠1) dose on either transit closed form is now also rejected with a clear error rather than silently mis-predicted — the closed form transits every dose into central regardless of compartment, so it would disagree with the ODE twin (which honours the dose compartment).Optional
[data]model-file block (#690): a model can now declarepath = ...to point at its own dataset ($DATAequivalent), soferx model.ferx,ferx check model.ferx, and the publicfit_from_files()(nowdata_path: Option<&str>) work without an explicit data path. An explicit CLI--data/Rdata =/fit_from_files()path still overrides the model’s[data]block, with a warning when the two differ (path equivalence, not textual equality, so a dir-joined model path and a raw external path to the same file don’t false-positive).-h/--helpflag for theferxCLI (#688):ferx --help,ferx check --help, andferx summary --helpnow print usage to stdout and exit 0, matching standard CLI convention (previously only printed on no-args/bad-args, to stderr, exit 1).ferx checkwarns when[scaling] obs_scalereferences the same individual parameter bound to a built-inpk <model>(...)block’sv/v1role (#712): the closed-form kernel already divides by that volume internally to produce concentration, so anobs_scalereferencing it divides by it a second time — a common mistake when translating anode(...)model (where that division is required) to an equivalent closed-formpkblock (where it already happens). Not rejected —obs_scalereferencing an individual parameter is also a supported feature for an intentional additional transform — just flagged, since ferx can’t tell intent from mistake.docs/model-file/scaling.qmdnow documents the raw-output convention per structural-model type.
Changed
- Flip-flop transit / inverse-Gaussian models with
lagtime/fnow auto-route to their ODE twin (#735) instead of being rejected. The analyticpk one_cpt_transit/two_cpt_transit/one_cpt_ig/two_cpt_igclosed forms clamp to an identically-zero profile outside their tilting-convergence domain (the flip-flop regime) and are transparently rerouted to the equivalent ODEtransit()/igd()model — but previously only when the model carried nolagtime=/f=mapping. Those mappings now carry into the generated twin (via reserved-name individual parameters), so a flip-flop model with absorption lag or bioavailability — including aTIME/ time-varying-covariate model that requires the twin — now fits and predicts correctly instead of erroring or returning zero. Validated by closed-form↔︎ODE equivalence tests (tests/transit_analytic_equivalence.rs,tests/ig_analytic_equivalence.rs) for lag, f, and both. A guard declines the twin (keeping the model closed-form, and rejected up front if flip-flop) whenever building it would misbehave: a parameter name that shadows a reserved F/lagtime slot it was not mapped to (which would silently apply an extra F/lag), or anf=/lagtime=parameter whose name collides with a disposition slot (e.g. a bioavailability parameter namedV1, which would otherwise make the twin fail to build). User[odes]/[scaling]/[initial_conditions]forms remain twin-less (no unique desugar) and are still rejected up front in the flip-flop regime. - Default thread count capped at 8 (#707): when
threadsis unset (or0/auto) — via[fit_options] threads, the CLI--threadsflag, or the R binding — the engine now defaults toavailable cores - 1(floored at 1), capped at 8, instead of one worker per logical core. Most fits gain little from scaling past a handful of cores, and not all cores are equal on asymmetric platforms (e.g. Apple Silicon’s E-cores). An explicitthreads = N/--threads Nstill pins the exact count requested. - CLI default output no longer writes a separate
{model}-timing.txtfile (#704): the estimation step’s wall-clock time and thread count now live under a newestimation:section in{model}-fit.yaml(narrower in scope than the old file, which also covered model parsing and data loading), alongside a newenvironment:section (OS, CPU architecture, whether running in Docker, OS username, ferx version) for troubleshooting and reproducibility. Both are also carried onFitResult.environmentand round-trip through.fitrxbundles. simulate()now samples inter-occasion variability (kappa) (#723): simulating an IOV model draws an independentkappa ~ N(0, Omega_IOV)for each occasion (matching NONMEM$SIM), instead of holding every kappa at zero. Simulated / VPC datasets from IOV models now carry the between-occasion spread the model parameterizes; previously they silently under-dispersed relative to the fitted model. Non-IOV models are unaffected (bit-identical output).- The adaptive frozen-replay verifier now independently validates its snapshots (#748): the default-on safety net for
simulate_adaptive/simulate_adaptive_from_specreused the same precomputed per-occasion / per-event PK snapshots the reactive driver did, so it checked that the two consumed them identically but never that they were correct — a mis-built snapshot (a wrong covariate, occasion, orkappafed to a parameter) was applied by both sides and passed the replay bit-exact. The verifier now re-derives each snapshot from the run’s primitives via the canonical helpers and bit-asserts it against what the driver was handed, so a build that mis-resolves a covariate or occasion (the class fixed in #732 / #739) fails loudly for every model instead of slipping past the replay. No effect on correct models.
Fixed
AGQ /
laplacefits no longer report “did not converge” while sitting on a settled OFV (#251). The gradient-based outer optimizer set NLopt’s stopping tolerances to1e-12, which FOCE/FOCEI can reach (their analytic gradient is exact to ~1e-11) but AGQ cannot: AGQ’s gradient is exact yet finite-difference-limited (the grid-response term and the posterior Hessian are central differences), so it carries a noise floor. L-BFGS ground on past the point where the objective had settled, until its line search failed on a noise-dominated direction and NLopt returned a bare failure — reporting “not converged” for a result that had been flat to eight significant figures. AGQ now stops on the reachable objective-change criterion (outer_ftol/outer_xtol), which both fixes the flag and makes the fits faster (AGQ+IOV on warfarin: 14.5 s → 8.2 s; Laplace+IOV: 2.2 s → 1.3 s), with identical estimates. FOCE/FOCEI are unchanged.Wrong analytic gradient for an observation sampled exactly on a moving dose boundary (#486) — a modeled infusion end, or a lagged dose arrival. With a modeled
RATE=-1/-2dose the infusion window end moves with the estimatedD{cmt}/R{cmt}. An observation whose time coincided with that end had its gradient taken along the moving boundary rather than at the sample’s own fixed clock time, adding a spurious term: on a 1-cpt fixture it returned−1.9, which is not even a valid subgradient in general. The closed-form walk now steps the read-out state back across the zero-length windowend(D) − t_obswith the infusion still running, recovering the derivative at the sample’s own time. Sampling at the end of an infusion is a normal design, so this is worth knowing about even though the exact coincidence is transient (Dis estimated, so it only sweeps past a fixed sample time momentarily).The prediction is genuinely kinked in
Dat that point — just above the coincidence the infusion is still running at the sample, just below it the dose has finished and a decay term appears — so no two-sided derivative exists there (the one-sided slopes are−8.6and+2.3, and a central finite difference returns their average,−3.2). ferx returns the one-sided derivative — specifically, the derivative of the branch ferx’s own event ordering already uses to define the value there (an infusion at its end is still contributing; a dose at its arrival has landed). That is the same convention its ODE engine’s jump/saltation sensitivities already used, and the two engines now return the same number.The same correction applies to an observation landing on a lagged dose arrival, where it matters more than it looks: on an oral model the prediction’s value at that instant is zero (the depot bolus has only just landed, so the central compartment is still empty) while its derivative is not — the closed form previously reported a derivative of zero, which looks innocuous and is wrong.
Note the deliberate consequence: at exactly such a coincidence an analytic gradient and a finite-difference gradient legitimately disagree — FD averages across a branch switch the model does not make there. That is a property of a kinked model, not a defect.
Multi-start (
n_starts) no longer returns a diverged run as the “best” fit (#830): the start selection preferred anyconvergedrun over an unconverged one before comparing OFVs, so a start that diverged — driving the residual covariance indefinite and reporting a ~1e20 sentinel OFV while still flaggedconverged— outranked a valid but unconverged start (including the exact-inits start 0). Multi-start could therefore return a worthless fit with an enormous OFV. Validity (a finite OFV below a divergence-large threshold) is now the primary ranking key, so a valid run always wins; converged-vs-OFV ordering is unchanged within a validity class.block_sigmacorrelated residuals no longer collapse the objective when a subject has two samples at the same time (#827): with ablock_sigma+ covariate-selected / per-CMT error model (the free-vs-total assay pattern), replicate assays at oneTIMEwere cross-correlated all-to-all, making the dense residualRindefinite so the FOCEI objective returned the invalid sentinel and the optimizer was repelled from the correct (correlated) optimum. Rows are now paired into disjoint correlated units — by the newL2column when present, otherwise one-to-one in co-temporal row order — keepingRpositive-definite. On the fluconazole 2-cpt binding model this recovers the NONMEM fit (OFV 742 vs NONMEM 734.6, previously stuck ~140 higher with a collapsed peripheral Q).block_sigmacross covariances within oneL2group are now correlated all-to-all (#830): an explicitL2group is the user’s declared correlated unit, so a genuine block of 3+ distinct endpoints (e.g. parent + two metabolites, each pair correlated) keeps its full cross-covariance structure instead of only one greedy pair. The disjoint one-to-one pairing that keeps co-temporal replicates positive-definite still applies to the implicit(time, occasion)fallback, where replicate rows cannot be told apart.Float-formatted
L2ids are no longer silently ungrouped (#830): pandas/R exports float-format the wholeL2column ("10.0") when any row is blank; the reader now accepts integer and float-formatted ids, so the user’sblock_sigmagrouping is honoured instead of every record falling back to ungrouped(time, occasion)pairing.block_sigmacross derivative is no longer dropped when a prediction hits zero (#830): the observation pairing is now decided from the loadings’ sigma-slot structure rather than the value covariance, so a pure proportional paired endpoint whose prediction is momentarilyf = 0keeps its (nonzero) slope cross term in∂R/∂f. This also stops the pairing from flickering as a prediction crosses zero between iterations.Standalone covariance step (
run_covariance) now reproduces the inline covariance bit-for-bit for FOCE/FOCEI fits: running the covariance step after a fit (covariance = falsethenrun_covariance, e.g. the R wrapper’s standalone SE step) previously rebuilt the parameters by re-decomposing the reportedomega(chol(L·Lᵀ) ≠ Lto machine precision). The resultingΩ⁻¹differed slightly from the one the inline step used, which the finite-difference Hessian amplified — up to ~10% on an ill-conditioned variance (e.g. a warfarin ω²(KA) with ~115% RSE), giving standard errors that disagreed with an equivalentcovariance = truefit. The step now reuses the optimizer’s exact packed vector when the fit carries one, so the covariance matrix and SEs match exactly — for every in-memory packed-Cholesky-space optimizer (NLopt, BFGS, trust region, Gauss-Newton). Fits reloaded from.fitrx, or from SAEM/importance-sampling/Bayes (which rebuildomegafrom the reported matrix on both paths), are unaffected — they already agreed. Surfaced during the #816 review.Closed-form transit/IG absorption under IOV / time-varying covariates now honors call-time ODE tolerances and converges its EBEs correctly (#814, #719 follow-up): a
one_cpt_transit/two_cpt_transit/one_cpt_ig/two_cpt_igmodel routes its IOV, time-varying-covariate, andTIME-switch subjects to an internally generated ODE “twin”. Three fixes to that reroute: (1) a call-timeode_reltol/ode_abstol/ode_max_steps(e.g. fromferx_fit(settings = …)) now reaches the twin’s solver — previously the twin silently integrated at its parse-default tolerance, ignoring the requested accuracy for the whole fit/predict; (2) the inner EBE loop’s ODE gradient-noise convergence stop is now enabled for those rerouted subjects, so an individual estimate that dropped to the finite-difference inner gradient no longer risks running to the iteration cap and returning an under-converged EBE; (3) when such a subject falls back to the FD inner gradient, the emitted diagnostic now names the precise twin-ODE reason (e.g. “steady-state dose + built-in absorption forcing”) instead of a generic “outside IOV analytic scope”. The converged population objective is unchanged (still NONMEM-anchored by the #719 transit+IOV cross-check). Regression tests intypes.rs/estimation/inner_optimizer.rs.Inner EBE line search no longer aborts on a non-finite objective (#719 follow-up): the FOCEI inner-loop backtracking line search (
estimation/inner_optimizer.rs) could panic withclamp(NaN, NaN)(a processSIGABRT) when a trial η drove the objective non-finite — e.g. an absorption closed form (transit/IG) evaluated at a step outside its convergence region, or a blown-up BFGS direction givingdg = ±inf. The quadratic step-length safeguard then producedinf/inf = NaN, which poisoned the step length and crashed the nextclamp. The search now rejects non-finite trials (falling back to plain halving) and non-finite directions up front, degrading gracefully to “no step” instead of crashing — surfaced while building the transit multi-dose + covariate NONMEM anchor. Regression tests inestimation/inner_optimizer.rs.Declining-hazard (
γ < 0) Gompertz median/mean survival diagnostics (#805):median_survivalreturnedNaNfor everyγ < 0Gompertz, even when a finite median genuinely exists — the closed form generalizes toγ < 0, so the reported TTE median is now finite whenever the cure fractionS(∞) = e^{−α·e^{loghr}/|γ|} < 0.5(and staysNaNwhenS(∞) ≥ 0.5, where more than half never event and the median is undefined).mean_survivalnow returnsNaNfor anyγ < 0Gompertz via an explicit guard: its mean is genuinely infinite (a positive cure fraction leaves a non-decaying survival tail), which the previous code reported correctly only by coincidence through theNaNmedian. Diagnostic/reporting only (predict_survivalsummaries); does not touch estimation or simulation numerics. Sameγ < 0boundary fixed for the samplers in #803 / #804.Declining-hazard (
γ < 0) Gompertz simulation no longer censors every event (#803): the analytic inverse-CDF event-time samplers guarded the Gompertz draw withinner ≤ 1, which fires for everyu ∈ (0,1)when the shapeγ < 0, so a declining-hazard Gompertz simulated an empty / all-censored stream even thoughfit()scored the sameγ < 0with finite density — simulate was not the inverse of fit for this family, breaking simulate→fit round-trips (VPC/SBC). Aγ < 0Gompertz has a finite limiting cumulative hazardH(∞) = −α·e^{loghr}/γ, i.e. a genuine cure fractionS(∞) = e^{−H(∞)}; the guard now rejects only that true cure fraction (inner ≤ 0), so a draw above it produces a valid finite event. Affects single-event TTE, clock-forward RTTE, and the clock-reset first sojourn for anyγ < 0.A dose landing exactly on a subject’s last observation is now applied before that observation is read (post-dose) on the constant-parameter ODE engine, fixing a false rejection by the adaptive frozen-replay verifier (#731). The engine applied doses only at each integration segment’s left boundary and treated the timeline’s final break as an endpoint only, so a dose coinciding with the last observation was dropped and that observation read the pre-dose state — disagreeing with the analytical engine, the reactive adaptive driver, and NONMEM (all post-dose). This surfaced as the default-on adaptive frozen-replay verifier rejecting a valid constant-covariate run whose final dosing decision coincided with the last sample. Interior breaks were already handled post-dose. The same terminal-break fix is applied to the two sibling break-walking paths so a terminal dose is read post-dose consistently everywhere: the dedicated dense state solve (
ode_dense_solve_states), keeping the joint PK-TTE hazard (#570) consistent between its shared one-solve and two-solve paths when an event time coincides with a dose at the subject’s last time point; and the per-compartment states path (ode_predictions_with_states), so the post-fit sdtab IPRED and compartment states agree with the fitted IPRED in that case.Adaptive/feedback dosing now runs the same dose-precondition guards as the static paths, instead of silently mis-delivering a dose (#721). The reactive entry points (
simulate_adaptive()/simulate_adaptive_from_spec()) skipped the modeled-RATE(#324), analytic-absorption closed-form, and built-in-absorption (#588) checks thatsimulate()/predict()/fit()all run before integrating, so a feedback-dosed model with malformed built-in-absorption pathway fractions or an out-of-domain absorption parameter simulated with a silently wrong absorbed dose. The guards now run at the adaptive chokepoint and fail with the same typed error before any decision is taken.A twin-less transit / inverse-Gaussian absorption fit no longer silently degenerates a subject whose fitted random effects reach the flip-flop regime (#785). An analytic
one_cpt_transit/two_cpt_transit(orone_cpt_ig/two_cpt_ig) model carrying alagtime/f/ user-[odes]mapping declines the ODE-twin desugar, and was only checked for the flip-flop regime at typical (η = 0) values at fit start. A subject whose empirical-Bayes estimate droveke = CL/Vpast the tilting abscissa still hit the closed form’s identically-zero profile, silently collapsing that subject’s likelihood contribution. The fit now emits a typedflip_flopwarning naming the affected subject(s) and pointing at the ODEtransit()/igd()forcing form (which reroutes per subject at the actual η). See the warnings documentation.simulate_with_uncertaintyno longer panics when a parameter draw enters the flip-flop regime (#786). For a twin-less transit / IG closed form whose point estimate is in-domain, a sampled uncertainty draw that crossed the flip-flop boundary previously aborted the entire simulation via a panic. Such draws are now skipped so the remaining draws still yield results (the run no longer panics; this aggregated-uncertainty entry point returns only the rows, so a skipped draw is not surfaced as a warning — usesimulate_with_optionswhen a skip must be visible). The single-shotpredict()/simulate()panic paths are unchanged.A time-to-event hazard that references an inter-occasion (IOV)
kappaby name is now rejected at parse instead of silently using zero (#770). A hazard is evaluated once per subject with no occasion context, so an IOVkappahas no well-defined value there. Referencing one through an[individual_parameters]value was already rejected (#442); a hazard expression that names akappadirectly (e.g.scale = TVLAMBDA * exp(KAPPA_CL)) previously fell back to a leniently-read0.0covariate, silently dropping the IOV term. It now fails loud, naming the offending random effect — write the hazard in terms of θ/η, or reference an IOV-free parameter.A degenerate hazard draw in simulation no longer vanishes silently, and a pathological RTTE hazard no longer aborts the whole run (#762, #763). When an analytic hazard’s effective rate degenerates (non-positive / non-finite), the affected subject is censored with no event — previously indistinguishable from ordinary administrative censoring; the
simulate_with_options_diagpath now names it in aW_TTE_DEGENERATE_HAZARDwarning. An RTTE hazard so extreme it would fire more than a million times over the window is now skipped (censored) with aW_RTTE_DEGENERATEwarning and the run continues for the rest of the population, instead of panicking the entiresimulate()call — and without first materialising ~1e6 rows. Thesimulate()/simulate_with_seed()entry points apply the same per-subject handling (no panic) but return only the rows.A time-varying covariate on a survival hazard is now a hard error instead of a silently frozen baseline value (#741). A
[event_model]hazard that references a covariate whose value changes within a subject was evaluated at the covariate’s baseline — the analytic hazard families take no time argument, and the joint PK-TTE ODE hazard integrates with the PK parameters frozen att=0— so the fit or simulation silently used the wrong hazard across every TTE / RTTE / competing-risks / joint-PK-TTE endpoint.fit()now rejects it (andpredict()/simulate()panic), naming the covariate and the subject. A time-varying covariate the hazard does not reference — e.g. one used only by a shared PK model in a frailty-only joint fit — is unaffected. Hold the covariate constant within each subject for now.An
[initial_conditions]covariate that matches no data column now fails the fit loudly instead of silently dropping the baseline (#765). A covariate named only inside an init expression (e.g.init(central) = CONC0 * V) was never registered as a required data column, so with a[covariates]block it was never read, and under auto-detect a case mismatch (CONC0vs aconc0header) resolved to0— zeroing the initial amount with no diagnostic (identical OFV with and without the block). Init-expression covariates are now registered like every other model covariate, so a missing or miscased name raisesE_MISSING_COVARIATElisting the available columns. Rename the column in[data](CONC0 = conc0) or match the header’s case in the expression.A dataset with dose rows but no
AMTcolumn is now a hard error instead of a silent bad fit (#753). When the amount column is named something other thanAMT(e.g. a NONMEM export usingDOSE), every dose parsed with amount 0, so no drug entered the system, the objective was flat, and the fit “converged” with every parameter pinned at its initial estimate. The reader now rejects such data withE_DOSE_NO_AMT, naming the fix (rename the column toAMT). A companion warningW_ALL_DOSES_ZEROfires when anAMTcolumn is present but every dose amount is 0 (e.g. a mis-scaled column).Loading a
.fitrxbundle whose data has two subjects sharing an ID no longer fails with a spuriouscorrupt or missing entryerror.load_fit(and thusferx summary) matchedpredictions.csvrows to subjects by ID, so when a dataset reuses an ID across subjects (e.g. an ID repeated across studies or a reset-split subject), every duplicate’s rows were routed to one subject and the other was left with zero rows — tripping then_obsconsistency check. Rows are now assigned positionally inebes.csvsubject order (which the writer already guarantees), with the row ID kept as an ordering cross-check.A forward reference in
[individual_parameters]is now a parse error instead of a silent zero (#710). A statement that referenced a name declared later in the same block (e.g.CL = ... * exp(IMAX*...)withIMAXdefined below it) previously resolved the not-yet-defined name to0.0— collapsing the formula (exp(0)=1) with no diagnostic fromferx checkor at fit time. Such an out-of-order reference is now rejected loudly, naming the offending variable; reorder the block so each name is declared before it is used. This mirrors the existing[odes]undefined-reference guard (#314).ADDLon a codedRATE=-1/-2dose no longer collapses to boluses (#722): additional doses expanded from a modeled-rate (RATE=-1/R{cmt}) or modeled-duration (RATE=-2/D{cmt}) record now stay modeled infusions like the first dose, instead of silently becoming instantaneous boluses. Previously a regimen such asAMT=100, RATE=-2, D1=2, ADDL=5, II=24fitted (and predicted/ simulated) as one modeled infusion followed by five boluses, with no warning.SS=2steady-state dose records are now rejected instead of silently run asSS=1(#729): NONMEMSS=2(superimpose the steady state of a regimen on top of the compartment’s existing amounts, without resetting) was collapsed to the same internalss = trueflag asSS=1(reset then equilibrate) — everySS >= 0.5cell becameSS=1— so anSS=2dataset fitted, predicted, and simulated with the wrong (reset) initial conditions and no warning. The data reader now accepts onlySS=0/SS=1and rejectsSS=2(and any other code) with a clear message. FullSS=2support is tracked in #694.An unmapped per-compartment
F{cmt}/ALAG{cmt}on an analyticalpkmodel is now a clear error (#725): these are ODE-only dose attributes. Naming an analytical individual parameterF1/ALAG1(or theLAGTIME1alias) without binding it to the model’s single dose route used to drop its value into an unused slot — so effective bioavailability stayed 1 / lag stayed 0 with no effect (a footgun when porting a NONMEM$PKthat setsF1/ALAG1). The parser now rejects that silent no-op, pointing at the baref=/lagtime=mapping (e.g.f=F1) or anode(...)model. A parameter that is correctly mapped (pk(..., f=F1)) is unaffected — its value was, and remains, applied as bioavailability/lag.Flip-flop transit models now evaluate correctly instead of returning a zero profile (#733): when a
pk one_cpt_transit(...)/two_cpt_transit(...)model’s individual parameters put the disposition rate at or above the transit rate (ke ≥ KTR, orα ≥ KTRfor 2-cpt — the flip-flop regime of a slow-absorption depot), the exponential-tilting closed form is outside its convergence domain and clamped the prediction and its gradient to0, silently degenerating a proportional-error objective.predict(),simulate(),fit()and the diagnostics now route such a model — per evaluation — to its exact ODEtransit()twin, which is valid in that regime (matched to a NONMEM ADVAN13 transit simulation to ~1e-4); a twin-carrying flip-flop model gets an informationalW_TRANSIT_FLIP_FLOPheads-up. A flip-flop model that carries alagtime, bioavailabilityf, or a user[odes]/[scaling]/[initial_conditions]block has no ODE twin to route to, so rather than silently returning a zero profile that degenerates the objective it is now rejected with a hard error (fit()returnsErr,predict()/simulate()panic,ferx checkreportsE_TRANSIT_FLIP_FLOP) — consistent with the other unsupported-transit rejects. Rewrite such a model as an explicit ODEtransit()model, or adjust the MTT / CL starting estimates.Fits are now reproducible regardless of the worker-thread count (#703). The FOCE/FOCEI, SAEM, and importance-sampling objectives summed the per-subject log-likelihood with a parallel reduction whose grouping depended on the number of rayon threads; because floating-point addition is not associative, the objective (and, in non-converged runs, the final OFV and estimates) differed between e.g. 4 and 15 threads. The per-subject contributions are now summed in a fixed subject order, so a given fit returns bit-identical results at any thread count.
CLI flags in
--flag=valueform are no longer silently ignored (#693):--data,--output,--threads(and any other value-taking flag) now accept=the same as a space, e.g.ferx model.ferx --data=d.csv --threads=4.TTE non-monotone-hazard guard now tracks the ODE solver tolerance (#618). For a drug-driven
[odes]hazard =expression (noh >= 0constraint), the cumulative- hazard monotonicity check rejected a negative incrementH(b) < H(a)only past a fixed1e-3*|H|round-off floor - 10x looser than the solver’s defaultreltol(1e-4) and growing without bound asHaccumulates, so a genuinely negative step up to ~0.1% of a large accumulatedHslipped through as round-off (admittingS = exp(-ΔH) > 1and biasing the optimizer toward the negative-hazard region). The floor is now tied to the model’s configuredode_reltol/ode_abstol(abstol + reltol*|H|, mirroring the integrator’s own per-step monotonicity tolerance), and the analytic closed-form path uses a tight fixed floor. Such a step now correctly folds into the1e20sentinel, while legitimate solver round-off on a flat/slowHstays finite.Built-in absorption pathway-fraction validation now covers
simulate()andpredict()(#588): a multi-pathway model with malformed fractions — a bare term alongside a fractioned one, a loneFR*fn(...), a fraction outside(0, 1], or fractions not summing to 1 — was rejected byfit()/ferx checkbut could be simulated or predicted with silently wrong dose delivery. The data-independent structural rules now fire at parse time (so every entry point rejects them), and the typical-value value checks are enforced on thepredict/simulatepaths too.Adaptive dosing now rejects models/data it cannot faithfully simulate, instead of silently returning wrong results (#391): a model with inter-occasion variability (
kappa/ IOV) or a stochastic ([diffusion]/ SDE) term, or a subject with a time-varying covariate or a system reset (EVID=3/4), now raises a typed error rather than being run with kappas held at zero, covariates frozen at their baseline value, process noise dropped, or the reset ignored.
0.2.0 - 2026-07-03
Added
- New
ferx summary <run.fitrx>CLI subcommand (#684): prints a concise,psn::sumo-style summary (parameter estimates with SE / %RSE, OMEGA / SIGMA with CV%, condition number, shrinkage, and run info) from a saved.fitrxbundle to stdout — no re-fitting or data required. - Log-transform-both-sides (LTBS) combined with IOV now gets an analytic outer (θ/Ω/σ) gradient (#486): the closed-form IOV sensitivity walk applies the
ln(f)jet after its in-walk scale quotient, reproducing production’s scale-then-log orderln(f/s), so LTBS × IOV models (including with anExpressionScale obs_scale) no longer fall back to finite differences on the population gradient. Validated against reconverged finite differences of the FOCEI-IOV objective. The inner EBE gradient still uses finite differences for LTBS × IOV. - Custom / time-varying residual-error magnitude combined with
iiv_on_ruvis now analytic under IOV too (#486): #673 covered the non-IOV case; the stacked[η_bsv, κ]residual-eta assembly is dimension-generic, so occasion (κ) random effects compose with the magnitude direct-θ terms with no extra work. Validated against reconverged finite differences. - Log-transform-both-sides (LTBS) analytic inner EBE gradient now covers the remaining closed-form combinations (#486): plain LTBS landed in #665; this extends the same
g = ln(f)inner jet to LTBS combined with an η-dependentExpressionScale obs_scale, with time-varying covariates (the event-driven inner walk), and with aTIME-built-in structural parameter. The inner η-gradient matches the outer to ~1e-10, and gradient-based HMC now engages for these models. LTBS × IOV still uses the finite-difference inner gradient. - Custom / time-varying residual-error magnitude combined with
iiv_on_ruvnow gets an exact analytic FOCEI outer (θ/Ω/σ) gradient instead of finite differences (#486): the residual-etac̃-column couplingd/Rgains its magnitude direct-θ terms, mirroring the σ-parameter block. Validated against reconverged finite differences of the FOCEI objective. prepare_frem()accepts a prior fit to seed FREM init values (#239). The new optionalfit_init: Option<&FremFitInit>parameter carries a completed fit’s theta and omega estimates; when supplied, the generated FREM model’s PK theta inits and PK-PK omega block are seeded from those converged values instead of the base model file’s declared inits, so a subsequent fit of the FREM model warm-starts closer to convergence. Names are matched case-insensitively against the base model; unmatched names fall back to the declared inits.Nonepreserves the prior behaviour unchanged.- Covariate-selected residual error models (
if/elsein[error_model]) (#658). The[error_model]block can now select a residual error model per observation by an arbitrary covariate condition — e.g. a free-vs-total assay switched by aFREEflag:if (FREE == 0) { DV ~ proportional(PROP_TOTAL) } else { DV ~ proportional(PROP_UNBOUND) }, withelse ifchains and a required finalelse. This mirrors the Form C[scaling] y = <expr>selector (#650), so a model can express both the readout and its residual error against the same per-row flag without recoding it into a syntheticCMTcolumn. Works on analytical and ODE models, across FOCE/FOCEI, Gauss-Newton, SAEM, and importance sampling. The selector covariate becomes a required data column (E_MISSING_COVARIATE).block_sigmacorrelated residuals are supported together with a selected error model (#669): co-temporal rows resolving to different branches (e.g. a total/unbound assay pair) pick up the cross-branch covarianceρ·σ_i·σ_jin the dense residualR, exactly as for per-CMT endpoints. See Error model → Covariate-selected error models. - Full
[scaling] y = <expr>output readouts (Form C) on analytical PK models (#650). A closed-form (pk one_cpt_iv(...), …) model can now replace the built-in concentration output with an arbitrary readout expression — enabling flexible multi-DV residual errors such as a free-vs-total protein-binding correction (y = if (FREE == 0) central/V + BMAX*(central/V)/(KD + central/V) else central/V), previously expressible only on ODE models. The readout may reference the central compartment amount (central, and the oraldepot), individual parameters — including non-structural ones like a bindingBMAX/KD— thetas, etas, covariates (read per-observation, so a per-row flag switches the readout), andif/else. FOCEI/FOCE gradients flow through it analytically (outer and inner) on both the static dose-superposition path and the time-varying-covariate / oral-infusion event-walk path — so a free-vs-total readout gated on a per-rowFREEflag stays analytic — including on IOV subjects (kappadeclarations) since #655, where the readout parameters are BSV-only (akappareference is rejected at parse) so only the concentration carries the occasion κ. A readout referencing the oral depot amount, per-CMT readouts, and direct θ/η references fall back to finite-difference gradients (the prediction stays exact, and the parser emits a warning). Peripheral compartment amounts are rejected (use an ODE model). See Scaling → Form C.
Changed
- Bumped
MAX_PK_PARAMSfrom 16 to 128, raising the ceiling on ODE structural parameters (rate constants, Emax/EC50, baselines, …) that an ODE model may declare in[individual_parameters]from 7 to 119 (slots 0–8 remain reserved for the named PK params CL, V, Q, V2, KA, F, Q3, V3, LAGTIME). Complex multi-analyte models — e.g. simultaneous parent/metabolite systems with ~20+ structural parameters — that previously failed the parser’s “too many individual parameters” check now compile. The ceiling is a compile-time constant because the Enzyme autodiff backend requires stack-allocated arrays of statically-known size; the cost of the higher ceiling is purely stack (MAX_PK_PARAMS * 8bytes perPkParams, ~1 KB at 128). - M3 BLOQ censored rows now enter the FOCEI Laplace determinant
log|H̃|for a consistent likelihood (#486). Previously censored rows contributed to the data term and the true inner Hessian but were dropped from the outerlog|H̃|— an internal inconsistency with quantified rows. They now enterH̃at FOCEI (Gauss-Newton) order (structuralg2·a·aᵀ, plus theiiv_on_ruvresidual-eta cross terms), with the exact analytic gradient matching reconverged finite differences to ~1e-6 across non-IOV/IOV and closed-form/ODE, including theM3 + IOV + iiv_on_ruvtriple. M3 FOCEI OFV values shift accordingly (estimates/SEs are essentially unchanged), and the OFV now matches NONMEMMETHOD=1 LAPLACEM3 up to the residual FOCEI-vs-LAPLACE second-order term. FOCE (Sheiner–Beal) is a distinct objective, updated separately (see the next entry). - FOCE (Sheiner–Beal) M3 BLOQ now uses the linearized-marginal moments for the censored tail probability —
−logΦ((LLOQ − f0)/√R̃ⱼⱼ)with the marginal meanf0 = f(η̂) − Hη̂and marginal varianceR̃ⱼⱼ = Hⱼ Ω Hⱼᵀ + R⁰, the same moments the quantified rows use — instead of the conditional prediction and residual variance (#646). This makes plain FOCE a self-consistent Sheiner–Beal objective (matching Monolix’s linearization likelihood and first-order/Tobit theory); the analytic FOCE gradient is updated to match, including a new direct Ω-gradient channel for the censored variance, on both the non-IOV and IOV paths. FOCE M3 OFV and estimates shift (most when between-subject variance is large, whereHΩHᵀdominatesR⁰). FOCEI M3 keeps the conditional censored term — the treatment NONMEM’sMETHOD=1 LAPLACEM3 uses (NONMEM runs M3 only under LAPLACE), which ferx’s first-order FOCEI matches up to the FOCEI-vs-Laplace∂²f/∂η²second-order term. - Removed the automatic SLSQP fallback after a non-converged outer optimization (#657). When the primary optimizer stopped without clean convergence, ferx used to silently re-run a full second outer optimization (with inner EBE loops) with SLSQP from the same point — roughly doubling wall-time on already-slow non-converged runs while rarely rescuing the fit. Non-convergence is now reported directly (
converged = falseplus the “Outer optimization did not converge” warning) with no automatic retry. Users who want SLSQP can still setoptimizer = slsqp. - IMPMAP/IMP’s FREM Rao-Blackwell E-step now runs a per-subject adaptive ISCALE pilot search instead of a fixed
iscale = 1.0(#406 follow-up). The RB conditional PK proposal is usually well matched, but for subjects where the inner-loop Hessian is a poor estimate of the true PK conditional curvature (sparse PK data), a fixed proposal width could leave ESS low even after RB. Mirrors the ISCALE rescue the full-dimensional sampler already had. Applies to the iterative MCEM E-step only; the eval-only / final-marginal IS report keeps a fixed proposal for run-to-run reproducibility.
Fixed
.fitrxbundles now carry SAEM conditional-distribution results (#675). A fit run withconddist = truewrites aconddist.csventry (ID, ETA, COND_MEAN, COND_SD, COND_MODE) into the bundle, and loading it back now populatesFitResult.cond_distinstead of always reportingNone— enabling the FeRx GUI’s “Cond. Dist.” Evaluation section to read this data from a saved fit.
Added
- Log-transform-both-sides (LTBS) combined with time-varying covariates now gets an exact analytic FOCE/FOCEI outer (θ/Ω/σ) gradient on the closed-form (analytical 1-/2-/3-cpt) models instead of finite differences (#486). The event-driven TV-cov walk applies the same post-walk
g = ln(f)jet transform the dose-superposition path already used — last, after anyScalarScale/ExpressionScalequotient, reproducing production’s scale-then-log orderln(f/s)— so LTBS composes with a time-varying-covariate and anExpressionScaleobs_scale. Validated against FD of the log-scale production predictor. - Plain closed-form LTBS now also gets an exact analytic inner EBE gradient (#486), not just the outer gradient — the light inner provider applies the same
g = ln(f)jet. Previously all LTBS models used a finite-difference inner gradient. Because the analytic inner gradient makes the marginal surface slightly noisier (thelnwrap amplifies the ~1e-9 provider-vs-predictor gap), a closed-form, non-IOV LTBS fit converges the inner EBE loop to at least1e-6(up from the1e-5default, unless you setinner_tolexplicitly) so the fit lands reproducibly on flat Ω directions and the covariance SEs of weakly-identified variances are stable; the covariance step then reconverges tighter still (see the next entry). LTBS combined with time-varying covariates, IOV, ODE, or an η-dependentExpressionScalestill uses the FD inner gradient (those inner kernels do not yet carry the transform, or already agree with the objective as ODE-LTBS does). Validated: the analytic inner η-gradient matches the outer, and warfarin LTBS covariance SEs match NONMEM$COV MATRIX=R. - New
[fit_options] cov_inner_tol— the inner EBE-reconvergence tolerance used only by the covariance step, decoupled from the fit’sinner_tol. The covariance R-matrix is a second-difference of the reconverged OFV and is more sensitive to EBE precision than the fit itself, so a sensitive/flat covariance can be reconverged tighter without slowing every outer iteration (e.g. the heavily-censored M3 + IOV case in #654 — setcov_inner_tol = 1e-11). Unset (default) usesinner_tolfor ordinary models — SEs are byte-identical to before — andmin(inner_tol, 1e-8)for closed-form, non-IOV LTBS models, whoseg = ln(f)covariance Hessian needs the tighter reconvergence. (The covariance step is not tightened blanket-wide: over-converging some ill-conditioned inner Hessians, e.g. IOV block-Ω, drives the covariance indefinite.) - A
TIME-built-in structural parameter combined with a built-in absorption input-rate forcing or a non-zero ODEinit(...)baseline now gets exact analytic FOCE/FOCEI sensitivities instead of finite differences (#486). The event-driven walk that threads the per-eventTIMEalready carries the absorptionR_inforcing (since #643) and seeds theinit(...)state (since #662), so the model-level decline for those combinations was stale; it has been removed. Validated against finite differences of the production predictor. - Several inter-occasion-variability (IOV) analytic-gradient cells that were arbitrarily narrower than their non-IOV counterparts are now analytic (#486, “IOV-scope parity”), closing gates that were more restrictive than the walk actually required:
- All built-in absorption input-rate kinds under IOV — the smooth densities
igd/transit/weibullnow get exact analytic FOCE/FOCEI sensitivities under IOV, not justzero_order/first_order/mixed/parallel. The IOV gate now mirrors the non-IOV kind-agnostic rule exactly; onlyweibull+ estimated lagtime (β<1 onset divergence) and any forcing combined with a steady-state dose remain on finite differences. - Compartment-indexed bioavailability
F{cmt}and lagtimeALAG{cmt}under IOV — the event-driven walk already resolves each dose’s own compartment slot, so these no longer fall back to finite differences. - A constant
ScalarScaleobs_scaledivisor under IOV on both engines — the trivial covariate-independent case of theExpressionScalequotient the IOV walk already applies: on the closed-form models the final jet is divided uniformly, and on ODE models the in-walk readout already dividesp/kover the stacked dual.
predict_iov(value, gradient, and Hessian over the stacked[η, κ]vector). - All built-in absorption input-rate kinds under IOV — the smooth densities
- Built-in absorption forcings (
zero_order(dur),first_order, andmixed) combined with inter-occasion variability (IOV) now get exact analytic FOCE/FOCEI sensitivities on the ODE path instead of finite differences (#486), closing the last zero-order gap. The IOV analytic walk is the same event-driven walk as the non-IOV time-varying-covariate path, so each forcing’s rate and moving-boundary window are rebuilt from that dose’s own per-occasion PK jet — the κ (occasion) sensitivity rides through exactly as η/θ do. Validated against finite differences of the productionpredict_iov(value, gradient, and Hessian over the stacked[η, κ]vector), including a κ-coupledDURaxis-placement check, aparalleltwo-first_orderpathway, and thefirst_order+ estimated-lagtime and+ EVID 3/4 resetcombinations. The smooth-density input-rate kinds (igd / transit / weibull) under IOV are now analytic as well (see the IOV-scope-parity entry above); only a built-in forcing combined with a steady-state dose under IOV remains on finite differences. - Modeled-duration/rate doses (
RATE=-1/-2) combined with steady-state dosing on the closed-form (analytical 1-/2-/3-cpt) models now get exact analytic FOCE/FOCEI sensitivities instead of finite differences (#486), the last modeled-dose gap after #652 (the ODE path had it via #642). The closed-form dual steady-state equilibration threads the modeled infusion-window jet(rate, dur)into each cycle’s active/quiet split, so the moving infusion-end flows through the steady-state trough exactly as it does through the current pulse. Validated against finite differences of the production predictor and against the independently NONMEM-anchored ODE steady-state modeled-dose twin. - Initial conditions (
init(...)/[initial_conditions]) are now fully analytic on the FOCE/FOCEI sensitivity gradient — every remaining combination that previously fell back to finite differences is closed (#486). A parameter-dependent baseline (e.g. an indirect-response or disease-progression PD baseline,init(central) = BASE/V) now gets exact analytic gradients when combined with: a finite infusion, a built-in input-rate forcing (igd/transit/weibull/first_order/zero_order), an estimated lagtime, steady-state dosing, a modeled-duration/rate dose, and an EVID 3/4 reset — on the ODE event-driven walk; the closed-form (1-/2-/3-cpt)initbaseline on the time-varying-covariate walk; andinitunder IOV on both engines (the amount stays BSV-only while the decay kernel follows each occasion’s clearance, matchingpredict_iov). Previouslyinitwas analytic only on the closed-form dose-superposition path (#527) and the ODE plain-bolus TV-cov subset (#649); everything else finite-differenced. Fits are unchanged — only the gradient path is now exact (and faster) for these models. - Zero-order absorption (
zero_order(dur), and thezero_orderleg of amixedmodel) combined with time-varying covariates or an estimated lagtime now gets exact analytic FOCE/FOCEI sensitivities on the ODE event-driven walk instead of finite differences (#486). The constantF·amt·frac/durwindow is delivered per integration segment, with its moving endd.time + lag + dur(and, under lagtime, its moving start) carried by rate-off / rate-on saltations; the rate-off uses the generalg⁻ − g⁺form so a covariate that varies across the window end stays exact. Onlyzero_orderunder IOV remains on finite differences. - Modeled-duration/rate doses (
RATE=-1/-2,D{cmt}/R{cmt}) on the analytical (closed-form 1-/2-/3-cpt) models now get exact analytic FOCE/FOCEI sensitivities instead of finite differences (#486), closing the largest closed-form-vs-ODE gap (the ODE path already had this via #630/#635). The modeled infusion window resolves from the PK parameters, so the infusion end is a moving boundary inD/R; the closed-form event-driven walk now carries it exactly (to second order) via the dual window length, the sign-mirror of the existing lagtime dose-start handling. Covers non-IOV and IOV (per-occasion windows, including κ-coupled slots), on both the outer θ/Ω/σ gradient and the inner EBE η-gradient. Validated against finite differences of the production predictor and against the independently NONMEM-anchored ODE twin. Modeled-dose × steady-state and rate-defined (RATE=-1) infusion underF ≠ 1remain on finite differences (as on the ODE path). - An
ExpressionScaleobs_scaledivisor (e.g.obs_scale = V) combined with IOV on a closed-form (analytical 1-/2-/3-cpt) model now gets exact analytic FOCE/FOCEI sensitivities on both the outer and inner loops instead of finite differences (#486). The scale divisor is applied as a per-occasion-group post-walk quotient over the stacked(θ, η, κ)axes — each occasion’s divisor rides its own κ through the PK parameters — porting the pattern already used on the ODE IOV path. Time-varying covariates compose (the divisor stays subject-static, matching NONMEM’s per-occasionS1scaling). LTBS and constantScalarScaleunder IOV continue to use finite differences. - Custom / time-varying residual-error magnitude (
[error_model]σ-scaling expression) now gets an exact analytic gradient on both loops instead of finite differences (#484/#576/#486). The magnitude is η-independent, so the inner EBE gradient just threads the per-observation multiplier into the residual variance and itsf-derivative; the FOCEI outer θ/σ population gradient additionally dual-differentiates the compiled magnitude program w.r.t. θ, adding a new direct-θ term to∂R/∂θfor any theta the magnitude expression references (e.g. a late-phase RUV inflationPROP_ERR * (1 + RUV_LATE * TIME/48)). Validated against a live NONMEM FOCEI fit (OFV and every estimate, includingRUV_LATE, match to ~4-5 significant figures — seeexamples/warfarin_ruv_magnitude.ferx). Plainmethod = foce(non-interaction) now gets the analytic gradient too, on both the non-IOV and IOV paths: the Sheiner–Beal marginal threads the magnitude into its typical-value residual varianceR⁰(value and direct-θ derivative), soautoresolves a FOCE magnitude model to a gradient-based optimizer instead of BOBYQA. The supported theta count is also raised from 16 to 32.block_sigmacorrelated residual error,iiv_on_ruv, an M3-BLOQ censored row, and more than 32 thetas still fall back to the (magnitude-aware) finite-difference gradient. init(...)initial conditions with time-varying covariates now get exact analytic FOCE/FOCEI sensitivities on the ODE path instead of finite differences (#486). The event-driven walk seeds the dual initial state from the subject’s first-record covariate snapshot (matching the production predictor’sinit_pk), so a covariate- or η-dependent baseline (e.g.init(central) = BASE / V) carries∂/∂(θ,η). Analytic for the plain-bolus subset;init(...)combined with an EVID 3/4 reset, an estimated lagtime, a finite infusion, a built-in input-rate forcing, steady-state, or a modeled-duration/rate dose stays on the finite-difference fallback.- Modeled-duration/rate doses (
RATE=-1/-2,D{cmt}/R{cmt}) under IOV now get exact analytic FOCE/FOCEI sensitivities on the ODE path instead of finite differences (#486). Each occasion resolves its own modeled infusion window from the per-occasion PK jet, and the moving infusion-end boundary carries∂/∂{θ,η,κ}— including when the modeled slot is itself κ-coupled (D1 = TVD1·exp(η + κ)). - Three more steady-state (
SS=1) ODE dosing combinations now get exact analytic FOCE/FOCEI sensitivities instead of finite differences (#486): a modeled-duration/rate dose (RATE=-1/-2), a rate-defined infusion under bioavailabilityF ≠ 1, and an estimated lagtime. The SS dual equilibration now threads the same mode-aware rate/window jet the non-SS event-driven walk uses into its per-cycle active/quiet split, and a lagged SS dose’s pre-arrival window[t_dose, t_dose+lag)is seeded from the previous interval’s steady-state tail (mirroring the production predictor’s own pre-arrival seed). Only SS combined with a non-autonomous RHS (one that readsTIME/TAFD/TAD) stays on the FD fallback — a time-invariant pulse train has no well-defined steady state under a time-dependent RHS. See Steady-state dosing. - A structural parameter that reads the event-time built-in
TIME/time(a NONMEM-style$PK IF (TIME.GE.45) CL=…time-dependent switch) now gets exact analytic FOCE/FOCEI sensitivities instead of falling back to finite differences (#486 / #610). The per-event time is threaded into the same event-drivenDual2(outer) /Dual1(inner EBE) walk used for time-varying covariates, so the gradient is exact and faster. Covers closed-form (1-/2-/3-cpt) and ODE models, with and without inter-occasion variability, including together with an η-dependentobs_scaleexpression (the event-driven walk now applies the scale quotient — which also makes time-varying-covariate + expression-scale models analytic). The directpk(...=TIME)structural mapping is covered too: the parser desugars the mapped slot into a hidden individual parameter (__ferx_pktime_*), so it rides the same per-event analytic walk as an[individual_parameters]switch. - A Form-C ODE readout (
[scaling] y = <expr>) that references a θ or η directly (e.g.y = central/V1 * (1 + ETA_CL) + TVBASE) now gets exact analytic FOCE/FOCEI sensitivities instead of falling back to finite differences (#486). The parser desugars each bareTHETA(i)/ETA(k)in the readout into a hidden individual parameter, so its∂y/∂θ/∂y/∂η(and the 2nd-order blocks) ride the same validated individual-parameter sensitivity chain ascentral/V1; the prediction value is unchanged and the synthetic parameters never appear in EBE / sdtab output. (A readout referencing a neural-network output stays on the FD fallback.) - Two more non-IOV ODE model combinations now get exact analytic FOCE/FOCEI sensitivities instead of finite differences (#486): a time-varying-covariate ODE model with (a) an
EVID=2covariate-only breakpoint, or (b) an η-dependentobs_scale = expr(θ,η)divisor. Theobs_scaledivisor is applied as a single subject-static post-walk quotient (production evaluates it at the subject covariate snapshot), and the EVID=2 breakpoint rides the event-driven walk that already carried it — closing the matching cells the IOV path gained in #590/#591. LTBS-combinedobs_scalestays on the FD fallback. - Built-in absorption input-rate forcing (
igd/transit/weibull/first_order/zero_order, incl.mixed) combined with an EVID 3/4 reset now gets exact analytic FOCE/FOCEI sensitivities instead of finite differences (#486): the fix threads the already-trackedreset_floorinto the shared forcing helper (turning off a dose’s pre-reset tail, matching the infusion rule) kind-agnostically, sozero_order’s own separate per-segment window mechanism (#530) inherits it too.igd/transit/weibull/first_order(but notzero_order/mixed) also now get exact analytic sensitivities combined with time-varying covariates, via the same helper wired into the event-driven walk, hoisting the forcing’s dose-invariant constants fresh per segment as the PK snapshot changes. Combined with an estimated lagtime,igd/transit/first_orderare also now analytic: the continuous∂R_in/∂lagflows through the walk’s dual time-after-dose, and the forcing’s onset at the dose’s lagged arrival is injected as an exact rate-on saltation.weibullstays on the FD fallback when combined with lagtime (its onset can diverge for shapeβ < 1), as doeszero_order’s own moving-boundary cutoff combined with TV-cov or lagtime (a separate per-segment mechanism not yet ported to the event-driven walk). - Analytic transit-compartment absorption (#386). A new
pk one_cpt_transit(cl, v, n, mtt)structural model evaluates Savic (2007) transit absorption into a one-compartment disposition as an exponential-tilting closed form (the incomplete-gammaconvolve_1cpt), with exactDual2FOCE/FOCEI sensitivities∂C/∂{CL,V,N,MTT,F,η}and no ODE solve — the fast analytic counterpart to thetransit()ODE forcing, with continuous (estimable)N. Supports single/multiple bolus doses, bioavailability, and lag time; withN = 0it reduces exactly to first-order (Bateman) oral absorption. Steady-state doses, IOV, time-varying covariates, infusions, and adepotinitial amount are rejected with an actionable message (use an ODE transit model for those). Seeexamples/one_cpt_transit.ferx. System resets (EVID=3/4) are also rejected, since the superposition closed form cannot express mid-profile compartment zeroing (#634). A typical-value warning (W_TRANSIT_FLIP_FLOP) now fires when the disposition rate exceeds the transit rateKTR = (n+1)/mtt(the flip-flop regime, where the closed form returns an identically-zero profile that would silently degenerate the objective) (#634). - Analytic transit absorption into a two-compartment disposition (#386). A new
pk two_cpt_transit(cl, v1, q, v2, n, mtt)structural model extends the closed form to a 2-cpt disposition: the Gamma(N+1, KTR) absorption time is convolved with the bi-exponential disposition (convolve_2cpt— twoconvolve_1cptterms at the macro-rates α, β), again with exactDual2FOCE/FOCEI sensitivities∂C/∂{CL,V1,Q,V2,N,MTT,F,η}and no ODE solve. WithN = 0it reduces exactly to 2-cpt first-order oral absorption. Same scope/limits asone_cpt_transit(bolus doses, bioavailability, lag time; SS/IOV/TV-covariate/infusion/depot-init/resets rejected, flip-flop warning). NCA initial-estimate seeding now peels Q/V2 and seeds the lag time for the transit models too (#634). Seeexamples/two_cpt_transit.ferx. block_sigmacorrelated residual errors are now supported undermethod = foceiandmethod = imp, not justfoceandsaem(#616). FOCEI carries the off-diagonal residual covariance through the Almquist interaction Hessian (H̃ = HᵀR⁻¹H + ½·tr(R⁻¹∂R/∂η R⁻¹∂R/∂η) + Ω⁻¹), and IMP builds its Student-t proposal precision from the denseR⁻¹. On the committedcorrelated_residual_combinedanchor, ferx FOCEI OFV 18.722087 matches NONMEMMETHOD=1 INTER(18.722087) to better than 1e-5. The Gauss-Newton (gn/gn_hybrid) paths remain diagonal-only and are still rejected.block_sigmacorrelated-residualfoce/foceifits now run exact analytic gradients on both loops instead of finite differences (#627). The within-observationcombined(...)cross term is carried through the same dense-Rbuilders the marginal uses (compute_dr_df_matrices,compute_d2r_df2_matrices), so the inner EBE η-gradient and the outer θ/Ω/σ gradient are noise-free and theautooptimizer resolves to a gradient-based method. The OFV is unchanged (Eval 1 on the anchor is still 18.722087); a rare cross-endpoint off-diagonal-Rsubject falls back to per-subject finite differences. (Thegn/gn_hybridpaths stay diagonal-only.)- AUC-target attainment metric + vancomycin AUC-TDM example/anchor (#391, S2.5b). A new optional
[adaptive_dosing] auc_target = [low, high]key addsauc_target_attainmenttoAdaptiveSubjectMetrics— the fraction of inter-decision windows whose area under the monitored signal (e.g. vancomycin AUC₂₄) falls in the band (highmay beinf). Liketarget_windowit reports a metric only and never influences dosing; declaring it turns on a signal-AUC pass that re-integrates the realized doses on a dense grid (trapezoid), leaving the reactive run untouched. A new bundled modelexamples/adaptive_vanco_auc.ferxtitrates a once-daily infusion on the pre-dose trough and reports AUC₂₄ attainment, cross-validated against an external mrgsolve run (reference kit intests/reference/vanco_mrgsolve/, slow-gatedtests/adaptive_vanco_anchor.rs). See Adaptive dosing. TIME/timeare now built-in event-time values in[individual_parameters]expressions and direct analyticalpk(...=TIME)mappings, enabling NONMEM-style time-dependent PK parameter switches without declaringTIMEas a covariate (#607). The event time is threaded through every prediction and diagnostic path — analytical and ODE predictions, the[odes]right-hand side, sdtab individual-parameter columns,[derived]columns, the survival/TTE hazard, and the SDE EKF — soTIMEresolves to each event’s time everywhere rather than only on the main prediction path; for models that use it, analytic FOCE/FOCEI sensitivities fall back to finite differences (#610).- Platelet-ladder adaptive-dosing example + mrgsolve external anchor (#391, S2.5a). A new bundled model
examples/adaptive_platelet_ladder.ferxexercises the reactive[adaptive_dosing]levelsladder on an oncology dose-modification scenario — a Friberg myelosuppression model whose simulated platelet count titrates the dose down a discrete ladder (100 → 75 → 50 → 25 mg). It is cross-validated against an external mrgsolve run, the apples-to-apples comparator for feedback dosing (NONMEM has none), which ferx reproduces dose-for-dose (reference kit intests/reference/platelet_mrgsolve/, slow-gatedtests/adaptive_platelet_anchor.rs). With this external anchor, adaptive (feedback) dosing graduates from experimental to beta. See Adaptive dosing. - Per-subject outcome metrics for adaptive dosing (#391, S2.4).
simulate_adaptive()andsimulate_adaptive_from_spec()now return ametricsfield onAdaptiveSimulationResult— oneAdaptiveSubjectMetricsrow per realized(subject, draw, sim)run: cumulative dose, dose-increase / -decrease / hold / discontinuation counts, time-to-discontinuation, and the observed-signal summary (min / max / mean). A new optional[adaptive_dosing] target_window = [low, high]key addspct_time_in_window(the fraction of the signal-bearing decisions whose observed signal fell in the band;highmay beinffor a one-sided target) — it reports a metric only and never influences dosing. Every metric is derived from the realized dose ledger and decision log alone. See Adaptive dosing. - Drug-driven event-time simulation for joint PK-TTE (#564).
simulate()/simulate_with_options()now sample event times for an ODE-accumulated hazard (hazard =in[event_model]), not just analytic families: the augmented ODE is integrated until the cumulative hazard reaches−log u, with the crossing located by a root-finder. A finite[simulation] horizon(orSimulateOptions.horizon) is required for these models — a drug-driven hazard can vanish and never fire, so there is no implicit observation window; EVID-3/4 resets and left truncation on an ODE-TTE subject are not yet supported and are rejected with a clear error. - Exact analytic gradients for M3 BLOQ + IOV models — full FOCEI/FOCE matrix (closed-form 1/2/3-cpt and user-ODE, #580/#591/#486). An inter-occasion-variability model with M3 below-limit handling now runs on exact analytic sensitivities instead of finite differences across the whole estimator matrix: FOCEI, non-interaction FOCE, and the triple M3 + IOV +
iiv_on_ruv. The censored data term−logΦ((LLOQ−f)/√v)and itsf-derivatives ride the stacked[η_bsv, κ]layout — censored rows enter the data gradient and the true inner Hessian but stay excluded from the LaplaceH̃/log|H̃|(matchingfoce_subject_nll_iov); foriiv_on_ruvthe censored residual-eta cross coefficients(C·z, C·m)enter the true inner Hessian and theh·zresidual-eta column enters the inner gradient. The FOCE path differentiates the augmented Sheiner–Beal marginal with censored rows re-entering as−logΦat the population (η=0, κ=0) variance. Inner stacked-η gradients match central FD of the IOV inner objective and outer packed gradients match Richardson reconverged FD of the corresponding marginal, all to ~1e-3; estimate-level tests confirm the analytic fits land on the FD (NONMEM-anchored) optima. This applies equally to user-ODE models (#486), including the triple M3 + IOV +iiv_on_ruv: the event-driven ODE sensitivity walk emits the standard per-observation shape (with a structural zero∂f/∂η_ruvcolumn for the residual-error η), and the censoring andexp(2·η_ruv)variance scaling are applied downstream keyed on theCENSflag andresidual_error_eta, so the ODE path rides the exact same analytic assembly as the closed-form path (inner and outer FD-comparison tests on censored ODE-IOV fixtures confirm both tails for plain IOV, M3 + IOV, IOV +iiv_on_ruv, and the full triple). The non-IOV ODE M3 +iiv_on_ruvcombination is analytic too — the lastiiv_on_ruvholdout: the ODE and closed-form packed gradients are bit-identical and both match reconverged FD to ~1e-7 on each censoring tail (inner and outer), completing the entireiiv_on_ruv× {plain, IOV, M3} × {closed-form, ODE} matrix. - Parallel / mixed dual-pathway absorption —
first_order(ka)composition (#505). A new built-infirst_order(ka)input-rate function exposes the classic first-order (Bateman) absorption for composition in[odes], so two absorption pathways can be split by a dose fraction:parallel(dual first-order,FR1*first_order(ka=KA1) + FR2*first_order(ka=KA2)) andmixed(zero-order + first-order,FZO1*first_order(ka=KA) + FZO*zero_order(dur=DUR)). A pathway fraction onzero_order(...)(FR*zero_order(...)) is now accepted (previously rejected), so the per-segment zero-order channel carries the fraction; the fractions must partition the dose (each0 < FR ≤ 1,Σ FR ≈ 1).parallelkeeps exact analytic FOCEI gradients (including ∂/∂fraction);mixeddifferentiates the zero-order duration/fraction by finite differences (the moving-boundary case, #530). Standalone first-order absorption still uses the analyticalpk *_oralpath. See Absorption models. - Joint PK-TTE — drug-driven hazard via
[event_model] hazard = <expr>(#564). On an ODE model, ahazardexpression that references the PK state (e.g.H0 * exp(BETA * (central / V))) is accumulated as a cumulative-hazard ODE compartment and estimated jointly with the PK by FOCEI/SAEM, with shared random effects. Mutually exclusive with the analyticfamilyhazard; requires an ODE model (no IOV yet). Simulation of the ODE-accumulated hazard follows in a later slice. See Time-to-event. - Custom / time-varying residual-error magnitude (#484). An
[error_model]sigma argument may now be an expression ofTIME, covariates, and thetas rather than a bare parameter — e.g.DV ~ combined(PROP_ERR * (if (TIME > 24) RUV_LATE else 1.0), ADD_ERR)— reproducing the NONMEM$ERRORidiom of a time- or covariate-dependent error coefficient. The expression scales that sigma’s loading per observation; magnitudes may depend only onTIME/covariates/thetas (not η or the prediction) and are supported formethod = foce/focei(the analytic gradient falls back to finite differences when active). [fit_options] outer_xtol/outer_ftol(#469) — expose the derivative-freebobyqaouter optimizer’s step (xtol_rel) and objective (ftol_rel) stop tolerances, previously hardcoded. Lets a fit tighten or loosen BOBYQA’s convergence on flat/noisy objective ridges. See fit options.- Defensive-mixture importance sampling for IMP/IMPMAP — new
imp_defensive_alphafit option (#528). Each subject can draw animp_defensive_alphafraction of its importance samples from the priorN(0, Ω), bounding the importance weights so a weakly-identified subject — e.g. an analytical[initial_conditions]baseline whoseVcancels in the amplitude — can no longer hijack the weighted M-step and walk θ to the bounds. The option is opt-in (default0.0, the legacy single-proposal sampler that stays bit-comparable with NONMEM); set a small positive value such as0.1to enable the rescue. Applies toimpandimpmap, including the FREM Rao-Blackwell path; for animpmapstage it may also be writtenimpmap_defensive_alpha. See Importance sampling. - IMP/IMPMAP and SAEM now flag a finite-but-enormous runaway objective (≥
1e15) as not converged, so a collapsed-weight blow-up can no longer reportconvergedor win multi-start selection (#528). - Experimental
simulate_adaptive()— state-reactive (“feedback”) dosing simulation (#553, epic #391). A programmatic entry point that simulates regimens where each dose is chosen at run time by a controller reading the simulated state (TDM target attainment, oncology dose reduction, biomarker titration). ODE models only; the controller is supplied as a per-subject factory; every realized dose and every decision (including holds) is returned alongside the trajectories, and a frozen-schedule replay verifier checks the dose bookkeeping by default. See Adaptive dosing. - Assay-noised (
Dv) monitors forsimulate_adaptive()(#566, epic #391). A controller can titrate on the realized, assay-noised measurement —IPRED + ε·√(residual variance), clamped at 0, drawn from the endpoint’s[error_model]— instead of (or, per-monitor, alongside) the latentIpred. This is the realistic TDM / titration signal. The assay draws come from a per-purpose RNG substream keyed by(subject, replicate, decision, analyte), so they are deterministic under a fixed seed, invariant to subject ordering, and never perturb another monitor’s (or η’s) draws. - Declarative
[adaptive_dosing]model-file block —simulate_adaptive_from_spec()(#584, epic #391). A reactive dosing policy can now be written in the model file — anobservesignal expression, a decision schedule (at),start_dose/route/dose_bounds, an optionalconfirmdebounce and discretelevelsladder, and a first-match-wins ladder ofwhen signal <op> value : increase/decrease/hold/stoprules — and run withsimulate_adaptive_from_spec(), no controller code required. It compiles to the same reactive engine, dose ledger, decision log, RNG substreams, and frozen-replay verifier as the programmaticsimulate_adaptive(); titrating on the assay-noised measurement (with_assay_error) reuses theDvsubstream. Exampleexamples/adaptive_tdm_titration.ferx. See Adaptive dosing. - Warn when no estimation method is set in the model file’s
[fit_options]or by the caller, making the implicit fallback to FOCEI visible instead of silent (#558). - Support NONMEM-style
block_sigmaresidual covariance under SAEM for ordinary Gaussian paired-endpoint models (#548). - Built-in zero-order absorption —
zero_order(dur)(#504). A new[odes]input-rate function delivering the dose at a constant rateF·Dose/durover the window(0, dur](a zero-order infusion whose duration is an estimated parameter, reusing theRATE=−2/D1modeled-duration machinery). Compose it with a hand-written- KA*depotfor sequential (zero-then-first-order) absorption. Like the other absorption inputs it routes the dose through the forcing (bolus suppressed), supportsF/lagtime/superposition, and requires an explicit ODE disposition (apk ... + zero_order(...)model errors, pointing atode_template). The hard cutoff attad = duris delivered exactly as a per-segment constant;dur’s gradient is finite-difference for now (the analytic boundary impulse is follow-up #530). Examplesexamples/zero_order_absorption.ferxandexamples/sequential_absorption.ferx. - Biphasic / parallel absorption via a pathway-fraction multiplier (#388). An
[odes]input-rate function may now be scaled by a declared individual parameter (FR*igd(...)), and more than one input-rate term may feed a compartment — so the Freijer & Post biphasic inverse-Gaussian model is written asd/dt(central) = FR1*igd(...) + FR2*igd(...), splitting the dose across two pathways. The multiplier must be a single declared parameter (not an expression like(1-FR)), so a two-pathway split declares a complementary fraction (FR2 = 1 - FR1); the fit-time check enforces0 < FR ≤ 1and that the fractions on a compartment sum to 1. The fraction’s gradient is exact (analyticDual2). Exampleexamples/biphasic_igd_absorption.ferx. (A fraction onzero_order(...), i.e. themixed/parallelzero-order family, is not yet supported — follow-up #505.) - Support NONMEM-style
block_sigmaresidual covariance across paired same-time multi-endpoint observations under FOCE (#546). - Support fixed residual-error correlations via
block_sigmafor FOCE combined-error models, with a NONMEM$SIGMA BLOCK(2) FIXvalidation example (#537). - Analytic FOCE/FOCEI gradients for Form C readouts that reference covariates (#540). An ODE Form C readout (
[scaling] y = <expr>) that branches on or scales by a covariate — e.g. a free→total protein-binding readout gated on aFREEassay flag — now gets the exact analyticDual2/Dual1gradient instead of falling back to finite differences. Covariates carry no parameter derivative in the individual-parameter dual basis the ODE sensitivity provider seeds, so they thread into the dual readout as constants from the per-observation covariate snapshot (consistent with #535/#538), for both the static and time-varying-covariate walks. θ or η referenced directly in a Form C readout (rather than via an[individual_parameters]entry) still falls back to FD. Validated on thefluconazole_radboudumcreadout shape (free/total fluconazole with saturable albumin-dependent protein binding): the analytic∂f/∂η/∂f/∂θmatch the production predictor and its central finite differences to ~1e-6 for both subject-static and per-observationFREEsnapshots (ode_provider_form_c_*tests). [data_selection]string equality on label columns, mirroring NONMEMIGNORE(C.EQ.C)(#536). A==/!=condition may now compare a covariate column against an unquoted label, matched against the raw cell value — so a non-numeric comment-flag column (the NONMEM convention of aCcolumn holding the literalC) is dropped correctly:ignore = C == C. The bare shorthandignore = Cexpands toC == C. A non-numeric value against a standard numeric column (e.g.DV == 0.O01with a letter O) is now a parse error rather than a silent never-matching no-op, and a clause referencing a column absent from the data emits aW_FILTER_COLUMN_ABSENTwarning instead of fitting unfiltered data silently.- Exact analytic FOCE/FOCEI gradients for η-dependent
ExpressionScaleobs_scale(#486), on both the analytical 1-/2-/3-cpt path (inner EBE gradient) and the user-[odes]path (outer θ/Ω/σ gradient and inner EBE gradient). A divisor scale such asobs_scale = 1000 / V(withVcarrying IIV) previously routed parts of the gradient to finite differences: on the analytical path the per-subject inner EBE loop reverted to FD (the outer was already analytic), and on the ODE path both loops did. The provider now applies the scale’s quotient rule∂(f/s)/∂x = (∂f/∂x)·s⁻¹ − f·(∂s/∂x)·s⁻²(x ∈ {η, θ}) over the differentiable scale program — the η-block for the inner loop, the full(θ, η)jet (including second-order blocks) for the outer — applied once per subject on the final prediction jet. The sameapply_expression_scale_*routines now serve the closed-form and ODE providers. Result-neutral (estimates and SEs unchanged; this removes FD steps, so the affected fits are faster and report the gradient method as “analytic”). On the ODE path the scale is served on the static walk only — combined with LTBS or time-varying covariates it still routes to FD, as does IOV +ExpressionScale. As a consequence the SAEM/Bayes HMC sampler now takes its gradient-based path (rather than the gradient-free Metropolis fallback) for closed-formExpressionScalemodels. Validated analytic ≡ production + finite differences (ODE outer), and light ≡ full provider (both inner loops). - Exact analytic FOCE/FOCEI gradients for steady-state (SS=1) ODE dosing (#439). User-
[odes]models with a steady-state dose now get exact analytic gradients instead of finite differences. NONMEM SS=1 loads the compartments with an infinite-past pulse train’s trough; there is no closed form for a general ODE, so production expands it as a finite(apply dose; integrate II)loop — running that same loop over the dual type propagates∂(steady state)/∂(θ,η)directly (no implicit fixed-point differentiation). Both SS boluses and SS infusions are supported (an SS infusion equilibrates with an active-rate window + quiet window per cycle), and SS composes with time-varying covariates, IOV, and EVID 3/4 resets. Routes to FD: a rate-defined SS infusion underF ≠ 1(its equilibration cycles would each need theF-scaled active window), and SS combined with an estimated lagtime (observations in the pre-arrival window[t_dose, t_dose+lag]must read the previous interval’s steady-state tail, which the dual walk does not yet seed — production handles it viass_state_at_phase). Result- neutral. NONMEM comparison: the SS=1 semantics this differentiates (the infinite-past pulse-train trough) are the production predictor’s, NONMEM-validated for SS dosing indocs/model-file/tests/; the analytic gradient is the exact derivative of that NONMEM-matching prediction (FD-confirmed viacheck_vs_production/predict_iov). - Analytic gradients for rate-defined infusion under bioavailability
F ≠ 1in[odes]models (#419). NONMEM holds a rate-defined infusion’s rate and scales its duration toF·amt/rate, soF’s sensitivity is a moving window boundary rather than a rate-magnitude scale — previously this routed to finite differences. The event-driven walk now carries it: the bioavailable window lengthF·amt/rateis the rate-off saltation boundary (combined with any lagtime shift), with the rate held. Such subjects route to the event-driven walk automatically. (A steady-state rate-defined infusion underF ≠ 1still uses FD.) Result-neutral. - Exact analytic FOCE/FOCEI gradients for IOV
[odes]models (#439). User-ODE models with inter-occasion variability (iov_column,kappa) now get the exact analytic outer (θ/Ω/σ) gradient over the stacked[η_bsv, κ₁..κ_K]random effects, via the event-drivenDual2walk seeded with per-occasion κ axes (the same walk the time-varying-covariate path uses, fed per-occasion parameters). Previously these fell back to finite differences. First cut covers bolus dosing, with or without time-varying covariates (each event is seeded at its own occasion × covariate snapshot); out-of-scope subjects (infusion, steady state, resets, lagtime, scaling/LTBS, IIV-on-residual-error, survival/TTE, orn_θ + n_η + K·n_κ > 16) route to FD as before. The inner EBE loop also uses an exact analytic stacked-η gradient (a light first-order walk), under the same model-level exclusions as the outer (it shares thegradient = fd/ escape-hatch /iiv_on_ruv/ FREM / TTE bails); the IOV outer is assembled per subject (exact analytic where in scope, per-subject reconverged-FD elsewhere), so one out-of-scope subject no longer forces the whole fit onto FD. NONMEM comparison: this is a gradient swap on the IOV FOCEI objective that is itself NONMEM-validated —tests/warfarin_iov_nonmem.rs(iov_objective_matches_nonmem,iov_individual_cl_matches_nonmem; OFV within ~0.6 units, all (ID,OCC) CL within 6.6%) anddocs/model-file/iov.qmd. The analytic gradient is result-neutral against finite differences of that same objective / the production predictor andpredict_iov. - Exact analytic inner EBE gradient for closed-form IOV models (#439). The inner EBE optimisation for analytical 1-/2-/3-cpt IOV models now uses an exact analytic stacked-
[η_bsv, κ₁..κ_K]gradient (a light first-order event-driven walk) instead of finite differences, matching the ODE IOV inner. Both IOV paths — closed-form and ODE — now have analytic gradients on the inner and outer loops. Result-neutral (validated against the second-order outer walk and finite differences of the inner objective). - Exact analytic FOCE/FOCEI gradients for ODE models with an estimated lagtime (#439). User-
[odes]models with an estimated lagtime — bareLAGTIME/ALAGor compartment-indexedALAG{n}— now get the exact analytic outer (θ/Ω/σ) gradient and inner EBE η-gradient instead of finite differences. Lagtime is an event-time sensitivity (the dose arrives att_dose + lagtime); it is handled on the event-driven walk via a per-dose event-time saltation injected at each dose and propagated through the per-event parameters, so it is exact across occasion / covariate boundaries and for per-compartment (non-uniform) lags — and fully analytic, with no finite differences (the one non-parameter-dual piece, the trajectory curvatureJ·ẋ, comes from a directional RHS evaluation). Composes with time-varying covariates, IOV, EVID 3/4 resets, and finite-duration infusions (for an infusion the window[t+lag, t+lag+ dur]shifts, so the saltation is applied at both rate boundaries). Lagtime + steady- state dosing routes to FD (pending the separate SS feature). Result-neutral — validated against the closed-form analytical twin (full Hessian), the production predictor (incl. TV-cov,ALAG1, reset, infusion), and finite differences ofpredict_iov/ the population objective. NONMEM comparison: the lagtime semantics this differentiates (dose/absorption shifted tot_dose + ALAG) are the production predictor’s, validated against NONMEM indocs/model-file/lagtime.qmd(NONMEM equivalence); the analytic gradient is the exact derivative of that NONMEM-matching prediction (FD-confirmed). - Event-driven analytic ODE sensitivities now cover EVID 3/4 resets and finite-duration infusions (#439). The TV-covariate / IOV event-driven sensitivity walk previously declined subjects with a reset or an infusion (→ finite differences); it now zeros the dual state at each reset (EVID=4 = reset + dose) and applies the per-event
F·rateforcing over each infusion window, so TV-cov and IOV models with resets or infusions get exact analytic gradients. Result-neutral. [initial_conditions]block for analytical PK models (#521). Declare a non-zero starting compartment amount withinit(central) = <expr>(orinit(depot) = ...) on a closed-form 1-/2-/3-cpt model — the analytical equivalent of NONMEM’sA_0(cmt)and of the ODE-pathinit(...)in[odes]. A pre-dose baseline (e.g.init(central) = CONC0 * V) no longer forces the numerical ODE solver: on the 6-thioguaninerun14model this cuts FOCEI wall time ~13× (27 s → ~2 s) at matching estimates. Non-IOV init models use exact analytic FOCE/FOCEI gradients undergradient = auto(#524); IOV init models usegradient = fdfor now. Edge cases are handled explicitly: the baseline is wiped by a system reset (EVID = 3/4), its decay uses each occasion’s PK parameters under IOV, aKAPPA_*reference in the init expression is rejected, and the combination with a steady-state dose (W_STEADY_STATE_INIT) or a compartment[derived]reference (W_DERIVED_INIT_ANALYTICAL) warns rather than silently mispredicting. See Initial Conditions.- Datasets whose TIME column does not start at zero (#573). ODE models now begin integration at each subject’s first record (matching NONMEM) instead of at a fixed
t = 0, so a subject whose first TIME is off-zero is no longer integrated over a phantom[0, first_record]window. TIME stays on the raw data clock everywhere — the modelTIME/Tbuiltin,[derived]columns, sdtab/predict/simulate output, and the survival left-truncationTENTRYall report the value in the data file; no per-subject time shift is applied.
Fixed
- A
one_cpt_transitmodel with aTIME-dependent structural parameter or time-varying covariates now works (#486). The transit closed form assumes constant parameters over each absorption window, so it cannot serve a subject whose parameters switch mid-profile; previously such a model was rejected (TIME/ TV covariates) or, on one internal path, produced a silently wrong all-zero gradient. For a plaincl/v/n/mtttransit model the parser now builds its exact ODEtransit()equivalent —d/dt(central) = transit(n, mtt) − (CL/V)·central,obs_scale = V, validated to predict identically to the hand-written ODE twin — and the prediction / gradient dispatch routes only the subjects the closed form cannot serve (aTIMEswitch, or time-varying covariates) to it, keeping the fast, exact closed form for every constant-parameter subject. Transit forms outside the equivalent’s scope (alagtime=/f=mapping, a custom[scaling], or an[initial_conditions]block) carry no equivalent and are still rejected up front (fit()errors;predict()/simulate()panic) rather than mis-predict — write the ODEtransit()model directly for those. Follow-up: the sdtab compartment/state ([derived]) columns for such a subject now come from the ODE equivalent too (previously they wereNaNbecause the states path did not route to the equivalent, even though IPRED did). - Finite / modeled-duration infusions combined with a time-varying covariate that changes across the infusion’s end now get an exact analytic second-order gradient (#486). The rate-off boundary sits between records, so the RHS Jacobian jumps there; the closed-form rate-off saltation assumed a single parameter set and dropped the
(J⁺ − J⁻)·xcurvature term, biasing the FOCEI Hessian / covariance-step SEs by a few percent (first-order gradient and OFV were unaffected). The infusion end now uses the same generalg⁻ − g⁺saltation as the zero-order window end. Cases without a covariate varying across the infusion end are unchanged.
Changed
- For
block_sigmacorrelated residual models, the SAEM reported OFV (the FOCE-approximation used for AIC/BIC) now follows theinteractionflag like FOCE/FOCEI instead of always using the non-interaction marginal: with interaction on (the default) it reports the dense interaction marginal (e.g. 18.7221 on thecorrelated_residual_combinedanchor, matching ferx FOCEI) rather than the previous non-interaction value (18.7274) (#616). The off-diagonal correlation is carried in both cases; only the marginal’s curvature term changed. - SAEM now warns on non-mu-referenced individual parameters instead of listing detected mu-referencing (#621). The broad
mu-ref: ...info notice is replaced by a SAEM-only warning that names any individual parameter whose random effect is not mu-referenced (e.g.CL = TVCL + ETA_CLrather thanCL = TVCL * exp(ETA_CL)), since such forms can strongly slow SAEM convergence. The warning fires whenever the estimation chain runs SAEM, independent of themu_referencingfit option. - IOV occasions with doses but no observations now contribute their own κ random-effect axis (#590). Occasion grouping (
iov_occasion_groups) now includes every occasion in the dose record, not only those carrying sampled observations, so a dose-only occasion (e.g. a loading dose with no PK samples) adds an IOV κ axis and ak_occasions·log|Ω_iov|prior term. This shifts converged OFV / estimates / SEs for datasets with dose-only occasions versus prior versions; it is intended (carryover means such an occasion’s κ is still informed by later observations). - FOCE + M3 BLOQ + IOV no longer silently promotes censored subjects to interaction (#591). Under
method = foce(non-interaction), an IOV subject withCENS != 0rows is now scored with a consistent Sheiner–Beal objective for the whole subject — the censored rows leave the linearized marginal and re-enter as−logΦ((LLOQ−f)/√R⁰)at the population (η=0, κ=0) variance — instead of being evaluated with η-interaction. This mirrors the non-IOV FOCE-M3 change (#367) and matches NONMEMMETHOD=1 LAPLACEwith vs withoutINTER: FOCE-IOV-M3 and FOCEI-IOV-M3 are genuinely different optima. Fits that relied on the old auto-promotion should setmethod = foceiexplicitly. The FOCE-M3 notice — which described the now-removed promotion (“evaluated with η-interaction”) — is reworded to state the non-interaction (Sheiner–Beal) semantics accurately (#599).
Removed
- The
covariance_ofv_hessianfit option and the analytical-gradient covariance R-matrix stencil it selected (covariance_ofv_hessian = false) have been removed. The covariance R-matrix is now always built from second differences of the reconverged marginal OFV — the accurate, envelope-free stencil that recomputes the full marginal curvature (a = ∂f/∂ηand thelog|H̃|EBE-response) at every perturbed point. The old analytical stencil heldafixed and biased the SE of weakly-identified structural parameters; its exact form requires third-order sensitivities (tracked separately). Models that setcovariance_ofv_hessianshould drop the key (it is now an unknown option) (#639).
Fixed
- Optimizer trace is now flushed to disk after every row so live consumers (e.g. the ferx-r trace UI) see iterations as they happen. The
TraceWriterwrapped the file in aBufWriterand only flushed atfinish(); high-volume methods (SAEM) filled the buffer and streamed incidentally, but gradient methods (FOCE/FOCEI/GN) emit few rows (smaller than the buffer) so the trace file did not appear until the fit completed. - Gradient optimizers no longer fail on a first-step overshoot into the EBE guard (#486). When the outer optimizer’s inner EBE loop rejected a trial step (too many unconverged subjects, or a non-finite OFV), the objective was clamped to a flat
1e20while the gradient was set to a non-zero “push back toward the bound centre” vector — an objective/gradient pair NLopt’s L-BFGS / SLSQP line search cannot reconcile (the slope of a constant is zero). For most fits this was harmless because the guard only triggers deep in the run; but a model whose first optimizer step overshoots straight into the guard (notably ODE models withiiv_on_ruv, where a large step diverges the inner EBEs and overflows theexp(2·η_ruv)marginal) failed on iteration one and never moved off the initial estimates. The guard now returns a quadratic penalty whose gradient is the push-back vector, so the line search backtracks to a feasible step and the fit proceeds. This makes the analytic M3 + IOV +iiv_on_ruvtriple on ODE models (#486) converge under the default gradient optimizer, matching the closed-form fit to estimator precision. - Estimation-method chains now run the covariance step only once, at the end of the chain (#615). When a chain ended in a default (estimating) IMP stage (e.g.
methods = [saem, imp]), both the preceding estimator and the IMP stage computed the (expensive) finite-difference covariance matrix — the trailing-IMP heuristic incorrectly treated every trailing IMP as an evaluation-only stage. The covariance / SIR step now runs only on the last estimating stage; evaluation-only IMP (imp_eval_only) still cedes the step to the preceding estimator as before. Plain chains without IMP were already correct. - M3 BLOQ above-ULOQ (right-censored,
CENS = -1) handling under FOCE and the analytic gradients (#591). The non-interaction FOCE marginal (foce_subject_nll_standard) and the analytic FOCE/FOCEI censored-row sensitivities (inner EBE gradient, outer θ/Ω/σ gradient, and theiiv_on_ruvcross-terms) hardcoded the lower (below-LLOQ) tail, so an above-ULOQ observation was scored and differentiated with the wrong normal tail — giving a wrong FOCE objective and a wrong-signed EBE/parameter gradient for any dataset withCENS = -1rows. The censored kernels and the FOCE marginal are now tail-aware (selectingz = (f − ULOQ)/√vforCENS < 0, matchingm3_logcdf). This also repairs the pre-existing non-IOV M3 right-censored gradient/objective (the bug predated the IOV work). Left-censored (CENS = 1) results are unchanged. - A time-dependent individual parameter written with the
TIMEbuilt-in inside a conditional-expression RHS now switches (e.g.MAINT = if (TIME > 45) 1 else 0). The “uses TIME” flag that routes such a model through the per-event evaluation path was computed after the individual-parameter statements were bytecode-compiled — a step that replaces theTIMEnode with anOp::PushTimeop the flag’s AST scan can no longer see. The flag therefore readfalse, the analytical path evaluated PK parameters once att = 0, and the parameter never changed over time (the effect collapsed and its θ became unidentifiable). The flag is now computed on the pre-compilation AST. ATIMEreference inside a fullif { … }statement block was unaffected; only the conditional-expression form regressed (introduced with theTIMEbuilt-in in #610). - A trough observation listed before a same-TIME dose is now evaluated pre-dose, matching NONMEM’s record-order semantics. The data reader ordered events by time and, at an equal TIME, placed the dose first on every path (the event-driven sort and the analytical superposition gate alike), so an observation sharing a dose’s timestamp was scored as a post-dose peak instead of the pre-dose trough the data intended. On trough-rich datasets this railed fits to their bounds. The reader now honors data record order: an observation written before its coincident dose sorts just before that dose, while a post-dose observation (dose row first) and steady-state doses are unchanged. The raw user-clock TIME reported in sdtab/covtab and by
predict()/simulate()is unaffected. With both fixes the infliximab run55 benchmark — which had railed to its bounds (eval-at-NONMEM-estimates OFV 3751 vs NONMEM 662; FOCEI and SAEM both converging to nonsense) — reproduces NONMEM: FOCEI OFV 664.0 vs 662.2, TVCL 0.198 vs 0.199, maintenance-phase CL multiplier 1.41 vs 1.40. - Joint PK-TTE fit now rejects a non-monotone (negative) cumulative hazard (#564). A drug-driven
hazard =expression is unconstrained, so a sign-flipped hazard could make the cumulative hazard decrease — implying a survivalS(t) > 1. The right-censored and exact-event likelihood terms previously accepted this silently (a finite, spuriously low objective that could pull the optimizer into the ill-posed region); they now return the same1e20sentinel as the other ill-defined cases. This matches the simulation path, which already hard-errors on a non-monotone cumulative hazard. - ODE+IOV fits now report their actual analytic-vs-finite-difference inner-gradient route, including subject-level fallback reasons, instead of using the non-IOV gradient probe for diagnostics (#590).
- ODE+IOV models with an expression
[scaling] obs_scaleand time-varying covariates now stay on the analytic inner/outer gradient route instead of falling back to finite differences (#590). - ODE+IOV models with EVID=2 covariate-only breakpoints now keep analytic inner/outer gradients when otherwise in scope; the breakpoint updates the ODE segment PK snapshot with κ fixed at zero, matching production prediction semantics (#590).
- ODE+IOV models with many occasion blocks or dose-only occasions now keep analytic inner/outer gradients when otherwise in scope, covering per-subject stacks up to 96 axes (#590).
- Wide ODE+IOV analytic gradients now run on larger Rayon worker stacks, avoiding native stack-overflow crashes in R/CLI release builds for PNA-scale occasion counts (#590).
- ODE+IOV fits no longer launch Nelder-Mead EBE fallback searches for bad outer trial points, and RK45 now exits repeated non-finite minimum-step clamps early, avoiding apparent stalls after rejected LBFGS steps in PNA-scale models while leaving finite-but-stiff segments to integrate normally (#590, #603). Subjects rejected at a pathological inner start now force the outer trial to be rejected outright — including in the SLSQP fallback — so a degenerate EBE can no longer bias an accepted OFV (#603).
- Standard errors for
thetaparameters with a negative lower bound (estimated on the natural scale — e.g. exposure–hazard slopes, covariate exponents) are no longer mis-scaled (#564). The delta-method back-transformSE(θ) = θ·SE(log θ)was applied to every theta, but it only applies to log-packed (non-negative) parameters; for natural-scale thetas the reported SE was multiplied by the estimate (and would flip sign for a negative estimate). Such thetas now reportSE = SE(packed)directly. Surfaced by the joint PK-TTE anchor, whereBETA’s SE matched NONMEM only after the fix. - Custom residual-error magnitude (#484) now applies on every path, not just the FOCE/FOCEI objective (#576). The per-observation multiplier was wired only into the OFV and silently dropped everywhere else, all without a guard:
simulate()/--simulateand NPDE drew residual error with a constant SD; the sdtab IWRES/CWRES columns (and downstream VPC/goodness-of-fit) were mis-scaled wherever the magnitude departed from 1; an ODE model underfoceiran its inner EBE loop with an analytic gradient that omitted the multiplier (mismatched against the magnitude-aware objective → biased η̂ and estimates); and a mixed PK+TTE model dropped the multiplier on its PK rows. All four paths are now magnitude-aware. The parser also now rejects a magnitude expression that references an undeclared covariate (including typos) even when the model has no[covariates]block — previously such a name silently evaluated to 0 and collapsed the multiplier to a constant. - TTE frailty ω² on a nonlinear hazard parameter now converges onto the NONMEM/nlmixr2 consensus (#469). The derivative-free
bobyqaouter optimizer false-converged on the near-flat ω² ridge — itsftol_reldefault (1e-6) stopped it short of ferx’s own objective minimum, so a Weibull shape-frailty read ω² 0.204 against the NONMEM LAPLACIAN 0.175 / nlmixr2 0.173 consensus on identical data. The TTE objective is evaluated exactly, so itsftol_relis now auto-tightened to1e-8(it lands 0.176); non-TTE fits keep1e-6to avoid grinding on noisy ODE/FD-inner objectives. This is a pure optimizer-convergence fix and does not touch the separate FOCEI-Laplace method bias (#440). - A diverged IMP/IMPMAP run is no longer reported as converged (#528). A collapsed-weight runaway pins θ to the parameter bounds and the final objective blows up to a finite-but-enormous value (~1e35); the convergence check only tested
is_finite(), so such a run could be flagged converged and even win multi-start selection. It is now treated as diverged. outer_maxiter = 0(NONMEMMAXEVAL=0) now means evaluation only on every optimizer (#562). The gradient NLopt path (nlopt_lbfgs/slsqp/mma) passedmaxiter = 0straight to NLopt’sset_maxeval, where0means no limit — so amaxiter = 0request silently ran a full fit and reported a converged, optimizer- and platform-dependent OFV instead of the objective at the initial parameters. All optimizers now route through a single eval-only path that runs one inner EBE solve at θ₀ and reports2·NLLthere (covariance step still honoured). This is what surfaced as thetwo_cpt_oral_cov_odeODE-vs-analytical “init OFV” diverging ~534 on x86 Linux in the ferx-r equivalence tests.- FOCEI now falls back to finite-difference h-matrices when an ODE analytic Jacobian is unavailable or non-finite, avoiding sentinel-inflated OFVs on sparse subjects such as the pembrolizumab RadboudUMC model (#551).
- Reject
block_sigmawith IOV until the IOV inner objective supports the full residual covariance matrix, use shifted times when pairing reset-segment residual blocks, and keep FREM CWRES variances unscaled byiiv_on_ruv(#549). - Inner EBE optimizer no longer spuriously fails on ODE objectives, fixing a wrong OFV for η-dependent
[scaling] obs_scalemodels (#555). The per-subject empirical-Bayes BFGS stopped only on its gradient norm, but an adaptive-ODE-solver objective puts a noise floor on the gradient that can sit above the inner tolerance — so a search that had already reached the mode spun tomax_iterand reported failure. The inner loop then discarded the correct estimate and restarted Nelder–Mead from η=0, which on a multimodal inner objective (e.g.obs_scale = V1withV1 = … · exp(ETA_V1)) settled in a worse local minimum and inflated the FOCEI objective (≈370 OFV on thetwo_cpt_oral_covexample; its analytical twin was unaffected). Two changes fix it, both scoped to ODE objectives so analytical, event-driven, FREM and finite-difference fits stay bit-identical to before: for ODE models the inner fallback (both the BSV and the IOV paths) now keeps the lower-objective of the BFGS partial and the Nelder–Mead restart instead of blindly overwriting with NM, and the inner BFGS gained an objective-stall stop so it converges at the mode rather than spinning. Exact objectives have no gradient-noise floor, so a BFGS failure there is genuine non-convergence and the historical NM-from-η=0 recovery is retained. The ODE form’s OFV-at-init now matches its analytical twin (−1193.59, previously−823.05), at the default ODE tolerance, and affected subjects converge in far fewer inner iterations. Note: withebe_warm_startoff (the default), an ODE fit that hits the inner fallback with a BFGS partial that beats the η=0 restart now returns that partial rather than the NM-from-0 result, so a previously fallback-stalled EBE/OFV may shift toward the better optimum. - Form C (
[scaling] y = <expr>) ODE readouts now use per-observation covariate snapshots (#535, #538). The explicit-output readout is evaluated against the covariate values on each observation’s own data row rather than the subject’s first-row values, so time-varying covariates referenced in a Form C expression now drive predictions, diagnostics, and the adaptive-trial decision monitors at the correct time. As a consequence, covariates referenced only from a Form C expression are now treated as required data columns: a model whose readout references a covariate absent from the dataset now fails loudly withE_MISSING_COVARIATE(and undeclared-but-present covariates raise the usual warning), where previously the missing value silently read as0.0. NONMEM comparison: validated against thefluconazole_radboudumcmodel (ADVAN3 TRANS4 with a free/total protein-binding$ERRORthat selectsCTOTwhenFREE==0andCUwhenFREE==1— paired assay rows at the same time). Evaluated at identical parameters, ferx’s per-record population predictions match NONMEM’sPREDto ~1e-4 relative on both the total-assay and free-assay rows (e.g. subject 1 at t=1: ferx 21.5105 / 2.9070 vs NONMEM 21.511 / 2.907), confirming the readout reads each observation’s ownFREEvalue rather than the subject’s first row. (The two rows at a given time differ only by that per-record covariate.) For time-constant covariates the readout is byte-identical to the prior behaviour; theode_event_driven_form_c_uses_observation_covariatesunit test pins the per-observation path. - Gradient-based outer optimizers now precondition with magnitude scaling (
Abs) instead of bound-half-width (Rescale2). Under the defaultoptimizer = auto(which resolves to NLopt L-BFGS when an analytic gradient is available),Rescale2was the wrong preconditioner and made FOCE/FOCEI converge to a parameter bound or a local minimum on several models — warfarin FOCEI stalled at OFV −243 (TVV 6.08) instead of −286 (TVV 7.74); a time-varying-covariate fit landed at a +166 local minimum with TVV pinned at its lower bound; SLSQP froze at its start on a 2-cpt covariate model. Switching the gradient-based optimizers (bfgs/lbfgs/nlopt_lbfgs/slsqp) toAbsscaling recovers the correct optimum in every case while preserving the SLSQP cold-start fix (#335). This fixes the downstream IMP/IMPMAP warm-start collapse and the simulation-based NPDE/NPD diagnostic, which inherited the bad fit. (Scaling is disabled automatically when an identity-packed covariate θ is present, as before.) - Exact analytic FOCE/FOCEI gradient for
iiv_on_ruv(IIV on residual error). Models with a residual-error eta (Y = IPRED + EPS·EXP(η_ruv)) now use the exact closed-form gradient on both the inner EBE and outer θ/Ω/σ loops, where the residual-eta column previously fell back to (and, with theauto/L-BFGS optimiser, silently mis-computed) a gradient that omitted theexp(2·η_ruv)variance scaling. The inner η-gradient scalesv/dv_dfand adds theΣ(1−ε²/v)residual-eta column; the outer assembly adds the Almquistc̃=2interaction column toH̃, the true-Hessian2ε²/R/κⱼaⱼterms, and theirlog|H̃|θ/Ω/σ derivatives. Validated to ~1e-11 against reconverged finite differences of ferx’s own FOCEI marginal (whose value is NONMEM-validated, #413). The assembly is provider-agnostic, so it covers the closed-form (analytical 1-/2-/3-cpt), ODE ([odes]), and LTBS (log_additive) paths — for LTBS the outer gradient is analytic while the inner EBE keeps finite differences (the existing LTBS choice, #438). IOV and M3-BLOQiiv_on_ruvkeep the finite-difference gradient. (#474) - Spurious “not referenced” warning for the
iiv_on_ruveta. A residual-error random effect is referenced from[error_model](not an individual-parameter expression), so it was falsely warned as “declared but not referenced … will not affect predictions or be meaningfully estimated” even though it scales the residual variance and is estimated. The warning is now suppressed for that eta. (#474)
Performance
- Analytic sensitivity gradients for moving infusion-end boundaries: modeled duration / rate doses and
zero_order(dur)absorption (#530). Three dosing features previously routed both the outer (θ/Ω/σ) and inner (EBE η) FOCE/FOCEI gradients to finite differences because the infusion end time is a moving boundary in an estimated parameter: aRATE=-2(D{cmt}, modeled duration) orRATE=-1(R{cmt}, modeled rate) dose (endt_dose + Dresp.t_dose + amt/R), and azero_order(dur)absorption forcing (endt_dose + dur). The dual walk now resolves the modeled rate/window from its PK slot as a live jet and carries the boundary derivative via the rate-off event-time saltation — the exact sign-mirror of the estimated-lagtime dose-start saltation (#472). Modeled duration/rate doses ride the event-driven walk;zero_order(dur)is delivered as a per-segment constant window (like an infusion) on the static walk, with the saltation injected at its cutoff. So these fits take the exactDual2/Dual1gradient (the estimates are unchanged; the gradient is faster and Hessian-clean). Validated against finite differences of the production predictor, with the modeled parameter η-coupled so both the θ- and η-blocks of the moving-boundary term are checked, plus inner/outer scope parity. Modeled duration/rate doses stay analytic when composed with an estimated lagtime (the start and end saltations carry the combinedδlag + δdurshift), an EVID 3/4 reset, time-varying covariates, or multiple doses;zero_order(dur)stays analytic across multiple doses and a mixed (zero- + first-order) pathway. Still FD: a steady-state modeled dose orzero_orderwindow (the SS equilibration reads a fixed per-cycle window), a modeled dose under IOV, and azero_order(dur)forcing combined with an estimated lagtime, an EVID 3/4 reset, or time-varying covariates (which keep it on the static walk’s FD fallback). - Joint PK-TTE fits integrate the augmented PK + cumulative-hazard ODE once per inner likelihood evaluation instead of twice (#570). For a drug-driven hazard (
[event_model] hazard = …), the cumulative hazard at the event/censor times is now read off the same integration as the Gaussian predictions by in-step cubic Hermite interpolation, rather than a second dedicated solve. The predictions are bit-identical and the hazard term is unchanged to integrator tolerance, so estimates and OFV are unaffected within the solver’s accuracy — the only difference is speed. Applies to plain ODE PK-TTE subjects (no time-varying covariates, EVID-3/4 resets, SDE, or FREM, which keep the previous path). - Analytic sensitivity gradients for ODE IOV models with an
ExpressionScaleobs_scaledivisor (#575). An[odes]model combining IOV (occasionkappa) with an η-dependentobs_scale = expr(e.g.obs_scale = V1) previously routed both the outer (θ/Ω/σ) and inner (EBE η) gradients to finite differences; each feature was analytic alone (IOV #466,ExpressionScale#534) but not together. The divisor’s exact quotient rule is now applied as a post-walk per-occasion-group jet over the stacked(θ, η, κ)axes, so these fits take the exactDual2/Dual1gradient — faster and Hessian-clean. Validated against finite differences of the production predictor and against the equivalent Form-C readout (y = central/V1). Still FD: the combination with LTBS or time-varying covariates, and the closed-form (non-ODE) IOV path. - Convergence-based early stop for steady-state equilibration (#519). The SS=1 pre-equilibration (both the f64 predictor and the
Dual1/Dual2gradient path, and the closed-form/event-driven SS loops) previously always expanded a fixed 50-cycle(apply dose; integrate II)train. It now stops once the trough stops moving — a shared mixedatol/rtoltest on the per-cycle increment (|Δ| ≤ tol·|cur| + tol·max,SS_EQUILIBRATION_TOL = 1e-12) applied identically across all paths, driven by the value parts so the dual truncates on the same cycle as the f64 path (making the gradient the exact derivative of the value the optimizer sees). The stop fires only after the value reaches its fixed point to f64 precision: fast disposition converges in ~14 cycles (~3.5× fewer), slow PK still runs the full budget. SS predictions are unchanged to f64 precision; gradients and covariance SEs match a full-budget run to< 1e-6relative (a small derivative tail, ~1e-8even on a deliberately scale-separated 2-compartment model, contracts a constant few cycles behind the value) — 3–4 orders below the1e-3gradient validation tolerance, the1e-9ODE solverreltol, and NONMEM’s ~1e-5SE-matching precision, i.e. invisible to every reported number. This was the dominant cost of analytic-gradient SS fits. - Exact analytic gradients for
[initial_conditions]models (#524). A non-IOV closed-form model with an[initial_conditions]baseline now runs FOCE/FOCEI on exact analyticDual2/Dual1sensitivities undergradient = autoinstead of falling back to finite differences: the init impulseA₀ · kernel(t, pk)and its θ/η dependence thread through the analytic provider (outer θ/η jet and inner η-gradient). Faster (no per-parameter FD probe) and exact, and it re-enables the HMC SAEM E-step (n_leapfrog > 0) for baseline models. The analytic gradient matches Richardson finite differences of the (NONMEM-validated) FOCEI marginal to ~1e-3. IOV init models keep the FD fallback (follow-up). - Exact analytic gradients for IOV +
iiv_on_ruvmodels (closed-form 1/2/3-cpt, #486). An inter-occasion-variability model that also puts IIV on the residual error (iiv_on_ruv) now runs FOCEI on exact analytic sensitivities instead of finite differences: both the stacked-η inner gradient and the outer θ/Ω/σ assembly carry theexp(2·η_ruv)residual-variance scaling and theη_ruvvariance column (the same treatment the non-IOViiv_on_ruvpath already used, #474). Faster (no per-parameter FD probe) and exact — the analytic inner gradient matches central FD of the IOV inner objective and the outer θ-gradient matches Richardson FD of the FOCEI marginal to ~1e-3. ODE IOV +iiv_on_ruvkeeps the FD fallback (follow-up). - Exact analytic gradients for closed-form
iiv_on_ruv+ M3 BLOQ models (#486). A model with IIV on the residual error and M3 below-quantification- limit handling now runs FOCEI on exact analytic sensitivities. The censored data term−logΦ((LLOQ−f)/√v)(withv = R·exp(2·η_ruv)) contributes the residual-eta columnh·zand the cross-curvature∂²L/∂η_ruv²,∂²L/∂η_l∂η_ruv,∂²L/∂η_ruv∂θ,∂²L/∂η_ruv∂σto the true inner Hessian and the mixed blocks, while censored rows stay excluded from the LaplaceH̃/log|H̃|(matching the objective). Inner η-gradient vs central FD and the outer packed gradient vs Richardson reconverged FD of the censored FOCEI marginal both match to ~1e-3. ODE M3 +iiv_on_ruvkeeps the FD fallback (not yet regression-tested). - Ω-preconditioned inner EBE loop for all FOCE/FOCEI fits. The inner BFGS now initialises its inverse-Hessian (the search
H0) to the prior conditional scalediag(1/Ω⁻¹ᵢᵢ)for every model, not just FREM. A correlated or multi-scale Ω (e.g. a block-Ω where one η has several× the variance of another) otherwise mis-scales the identity-H0search, costing extra inner iterations. The convergence test stays the raw L2 gradient norm for general fits (only FREM needs the preconditioned norm, issue #406), soH0changes only the path to the mode — the converged EBE and the estimates are unchanged. On the two-compartment UVM FOCEI/MMA benchmark this cuts inner BFGS steps per EBE solve ~25→16 and total predictions ~17M→6.2M for a ~1.23× faster fit (single- and 8-thread) at the same optimum (OFV within 4e-5 of the prior result; matches NONMEMrun18). - Interpolating inner-loop line search (#462). The EBE BFGS line search now picks each trial step by safeguarded quadratic interpolation instead of fixed halving, and reuses the objective value the optimiser already tracks instead of recomputing it. On the two-compartment UVM FOCEI/MMA benchmark this cuts the average backtracks per line search from ~22 to ~3 (cap-exhaustion 20% → 0.1%), roughly halving the prediction-walk count for a ~2.5× faster single-threaded fit at the same optimum.
- Reuse per-thread scratch buffers when evaluating individual PK parameters, reducing allocator traffic in FOCE/FOCEI inner loops with time-varying covariates (#462).
- Exact analytic gradients for
transit()absorption ODE models (#430). The built-in transit input-rate forcing’sln Γ(n+1)constant now has aDual2rule (analytic digamma/trigamma derivatives of the shared Lanczosln_gamma), so atransit()model is evaluated overDual2by the ODE sensitivity provider and drives exact analytic FOCE/FOCEI/Bayes gradients instead of finite differences — joiningigd()on the analytic path. Estimates are unchanged; gradients are exact and drop the(n_params+1)×FD multiplier on transit fits. - Faster analytic time-varying-covariate inner η-gradient + ODE-sensitivity path consolidation (#451). The per-subject event schedule is now reused across inner BFGS steps instead of rebuilt each step, identical per-event covariate snapshots are seeded once, and the time-after-dose anchor advances incrementally — cutting redundant work in the inner EBE loop for TV-covariate analytical fits. Internally, the production
f64and dual ODE-sensitivity paths now share single generic helpers for the built-in absorption input-rate forcing and the LTBS log transform, so the predictor and the analytic gradient can’t silently drift; no change to results. - Analytic inner η-gradient for time-varying covariates / oral infusion on analytical PK models (#447). The light
Dual1inner EBE gradient previously declined these subjects and reverted to finite differences even though the outer gradient already served them; it now uses a first-order event-driven walk (subject_eta_grad_tvcov, the light mirror ofsubject_sensitivities_tvcov), so the inner EBE loop is exact and replaces FD’s~2·n_eta+1predictions per step with one. Validated against the FD-validated outerdf_deta(1-/2-/3-cpt, IV/oral, steady state). - Constant-fold covariate-only individual-parameter sub-expressions in the analytic sensitivity walks (#485). The
[individual_parameters]block is re-evaluated on every inner-EBE and outer-gradient step; for covariate-heavy models its covariate-only prefix (e.g. CKD-EPI / Schwartz / FFM / maturation — often the bulk of thepow/exp/logwork) does not depend on θ or η, yet was carried throughDual2/Dual1arithmetic (gradient + Hessian per operation) every call. The parser now classifies those slots once at compile time and theDual2/Dual1providers evaluate them once in plainf64and seed them as dual constants, skipping the redundant dual re-derivation. Numerically identical (bit-for-bit gradients and Hessians); only θ/η-free slots are folded, so all dual axes — including∂/∂θ_fixed— are preserved. On a jasmine-style covariate kernel (8/10 slots foldable) this is ~1.7× faster perDual2individual-parameter evaluation. Found while profiling the jasmine vancomycin-pediatrics FOCEI fit. - Light
Dual1inner η-gradient for analytical PK models (#491). The inner EBE loop’s∂p/∂ηfor analytical 1-/2-/3-cpt models was computed over the fullDual2<n_theta + n_eta>(carrying the θ-axes gradient and the second-order Hessian) and then all but the η-block discarded. It now uses the lightDual1<n_eta>walk the ODE inner loop already used (#410), seeding η only — so e.g. a 10-θ / 4-η fit drops aDual2<14>(14-vector grad + 14×14 Hessian per op) to aDual1<4>. Converged EBEs and OFV are unchanged (the inner gradient method only affects the path to the mode); validated by the existing analytic-vs-FD inner-gradient tests. Also serves models whose combinedn_theta + n_etaexceeds the dual dispatch ceiling but whosen_etadoes not (previously an FD fall-back).
Added
- Built-in Weibull absorption — the
weibull(td, beta)input-rate function (#322, Phase 2). Use it inside an[odes]RHS, withtd(scale) andbeta(shape) bound to[individual_parameters](so they carry IIV / covariates for free):d/dt(central) = weibull(td=TD, beta=BETA) - CL/V*central. The dose is delivered as the Weibull density over time (∫R_in dt = F·Dose) and its bolus is suppressed — the same dose-into-the-input-rate-compartment convention astransit()/igd(). Shapebetaselects the profile:>1a delayed interior peak,=1first-order absorption withka = 1/Td,<1fast early uptake (an integrable spike at the dose). Weibull has no elementary closed form, so it always runs on the numerical ODE path and requires an explicit ODE disposition — combining it with an analyticalpk ...is a clear error pointing atode_template. Because the forcing is evaluated overDual2, aweibull()model drives exact analytic FOCE/FOCEI/Bayes gradients (no finite-difference fallback), validated against NONMEM. Seeexamples/weibull_absorption.ferxanddocs/model-file/absorption.qmd. - Analytic FOCE/FOCEI gradients for compartment-indexed bioavailability (
F1/F2, …) on ODE models (#486). An ODE model that sets a per-compartment bioavailability now drives the exact analytic outer gradient and lightDual1inner η-gradient instead of finite differences: both the static and time-varying-covariate dual walks resolveFper dose compartment (the indexedF{cmt}slot, else the bareF), matching production’sDoseAttrMap::f_bioand carrying∂/∂F{cmt}exactly. Estimates are unchanged; the gradient is exact and cheaper. Validated by an analytic≡production+central-FD parity test (single indexedF1with IIV, and distinctF1≠F2dosed into two compartments). Per-compartment lag (ALAG{cmt}) stays on FD for now (→ #472). ebe_warm_startfit option (defaultfalse, opt-in). When a per-subject inner BFGS solve fails and falls back to Nelder–Mead, seed the simplex from the BFGS partial η̂ instead of cold-starting from the prior mode η=0. On fallback-heavy fits (e.g. an unidentifiable peripheral volume that drives BFGS far onto the steep prior slope) NM then converges in a fraction of the iterations — ≈1.7× faster on a 2-cpt unidentifiable-V2 benchmark. Off by default because warm-starting moves the fallback subjects’ EBEs, which perturbs the outer optimiser’s trajectory: harmless for the BOBYQA default but can derail a gradient-based outer optimiser (e.g.mma) into a worse basin on some models. Validate OFV/estimates on your model +optimizerbefore enabling.- Competing-risks TTE (cause-specific hazards) (#440). Multiple
[event_model NAME]blocks on distinct compartments now model mutually-exclusive event types that share the model’s random effects (a common frailty).simulate()draws the competing causes correctly — the earliest latent event is observed and the others are right-censored at that time — andpredict_survival()gains a cause-specific cumulative incidencecifplus the all-cause survivalsurvival_all(withΣ_k cif_k(t) + survival_all(t) = 1), the correct competing-risks quantities. Exampleexamples/tte_competing_risks.ferx. Behind thesurvivalfeature. [simulation] horizonfor TTE / competing-risks VPC (#522). A newhorizon = <t>key sets an administrative censoring time that is decoupled from the observed event times: when present it overrides each TTE record’s per-record observation window, so re-simulating event-bearing data (a VPC) censors every cause at the planned study endtinstead of drawing unbounded. It is also honoured by the[simulation]-block--simulatepath, which now generates one right-censored TTE row per cause compartment per synthetic subject (a TTE model under[simulation]therefore requireshorizon); previously that path emitted zero TTE rows. Exposed on the librarySimulateOptions { horizon }. Behind thesurvivalfeature.[event_model]hazard expressions can reference[individual_parameters]names — e.g. a hazard driven by an individualCL— resolved per subject at evaluation time, in addition to the existing theta/eta/covariate namespace. Intermediate variables and names defined with a NONMEM-styleif (...) { ... } else { ... }block are supported; only the individual parameters the hazard actually references are computed. A hazard reference to an individual parameter that depends on an inter-occasion (IOV/kappa) random effect — or on a[covariate_nn]output — is rejected with a clear error, since the per-subject hazard cannot evaluate either. Behind thesurvivalfeature (#440).- Analytic FOCE/FOCEI gradients for time-varying covariates on ODE models (#439). An ODE model whose covariates change over time (per-event
WT,CRCL, …) with bolus dosing now gets the exact analytic outer gradient and the lightDual1inner η-gradient instead of falling back to finite differences. The dual is seeded on(θ,η)(M = n_theta + n_eta) and walked over a per-event event-driven integration, mirroring the analytical TV-cov path and matching production’sode_predictions_event_drivenpredictor bit-for-bit (validated against it + FD). Combined with infusion / steady-state / reset /init(...), TV-cov still falls back to FD. - Analytic gradients for per-CMT (multi-endpoint) ODE readouts (#439). The
[scaling] y[CMT=N] = <expr>Form-C readout is now differentiated by the ODE sensitivity provider — each endpoint’s compiled output program is evaluated overDual2(outer) andDual1(inner), dispatched per observation by its CMT — so multi-analyte / PK-PD models (e.g. parent + metabolite, or PK + effect) get the exact analytic FOCE/FOCEI gradient instead of falling back to finite differences;gradient = fdis no longer required for these models. Validated against finite differences of the production predictor. - Analytic FOCE/FOCEI gradients for user-
[odes]models (#410). The ODE sensitivity engine — an augmentedDual2RK45 that propagates∂state/∂(θ,η)alongside the state — is now armed, so in-scope ODE models drive the exact analytic outer gradient (and the Eq. 48 EBE predictor) instead of the prior gradient-free path. The inner EBE loop likewise gets an exact η-gradient from a lighterDual1(gradient-only) walk — one integration per inner step in place of finite differences’2·n_eta+1, so the EBE search is exact and faster. Scope: RHS-program models with anObsCmtor simple Form-C (y = central/V1) readout, bolus + finite infusion, bioavailabilityF, EVID 3/4 resets,init(...), static covariates, a constantobs_scaledivisor, and LTBS (log(DV) ~ …) output transforms. Out-of-scope features (steady state, estimated lagtime, IOV,input_rate, SDE, time-varying covariates, expressionobs_scale, modeled-RATEdoses,Fon a rate-defined infusion) fall back to the existing path unchanged. Validated against finite differences of the production predictor, reconverged FD of the FOCEI marginal, and a full-convergence cross-check that an ODE fit reproduces the analytical (NONMEM-validated) twin’s estimates and standard errors. - Analytic sensitivities for oral infusion on the analytical 1-/2-/3-cpt models: a depot-bypass infusion into the central compartment (RATE>0 into cmt 2, #350) and a zero-order input into the oral depot (RATE>0 into cmt 1, #400) are now carried through the second-order-dual event-driven walk (
rate_central/rate_depotforced responses), so these subjects drive the exact analytic FOCE/FOCEI gradient instead of falling back to finite differences. Validated against finite differences of the production predictor across 1-/2-/3-cpt and both infusion compartments (#367). - Analytic sensitivities for expression output scaling (
[scaling] obs_scale = <expr>) on analytical PK models. Anobs_scaleexpression that references individual parameters, θ, or covariates (e.g.1000 / V,WT / 70) is now compiled to aDual2-differentiable program, so the analytic FOCE/FOCEI outer gradient differentiates the scaled predictionf / scaleexactly (quotient rule) instead of falling back to finite differences. Validated against finite differences of the production predictor and against a NONMEM reference (#367). - Analytic sensitivities for inverse-Gaussian (
igd()) absorption on ODE models: the built-in input-rate forcing is now evaluated overDual2by the analytic ODE sensitivity provider, so anigd()model drives exact FOCE/FOCEI/ Bayes gradients instead of falling back to finite differences (estimates unchanged; gradients exact and cheaper). The forcing was lifted to aPkNum-generic form; transit (transit()) still uses FD pending its ownln_gammaDual2rule. Validated by an analytic≡central-FD gradient parity test in the default build (#430).
Changed
optimizernow defaults toauto(#490). The newautochoice picks the outer optimizer per model:nlopt_lbfgswhen the exact analytic FOCE/FOCEI gradient is available, andbobyqawhen only finite differences are (ODE/PD models, LTBS/SDE, orgradient = fd). Limited benchmarking across ~10 real FOCEI datasets foundnlopt_lbfgsfastest-to-optimum on every analytic-gradient problem andbobyqafastest and most reliable on the finite-difference ones, soautogives most users a good default without tuning. The fit output reports the resolved pick asauto (<resolved>); setoptimizerexplicitly (e.g.optimizer = bobyqa) to keep the previous fixed default.- The SLSQP fallback no longer triggers on
MaxEvalReached(#499). After the primary NLopt run (nlopt_lbfgs/slsqp/mma), ferx retried from the current point with a fresh, full-budget SLSQP optimization whenever the primary didn’t report a clean convergence code — including when it simply hit the evaluation budget. A spent budget is not a failure a second optimizer can fix (it just doubles the cost); ferx now emits an “increasemaxiter” warning and returns the best-seen point instead. The genuine-failure fallback (Failure/RoundoffLimited) is unchanged. Found during the jasmine FOCEI slowness investigation. optimizer = lbfgsandoptimizer = bfgsnow select the NLopt L-BFGS (nlopt_lbfgs) instead of the hand-rolled built-in BFGS / limited-memory L-BFGS (#483). Across analytic-gradient FOCEI benchmarks (jasmine, infliximab, uvm) the NLopt path reaches the best OFV and is 3–5× faster than the built-in, which on harder fits diverged (infliximab) or hung with no outer progress (busulfan ODE+IOV). The two keys are now deprecated aliases; the built-in implementation is slated for removal. The NLopt path’s accuracy is validated against NONMEM/nlmixr2 reference fits on the Outer Optimizers page (e.g. warfarin LTBS OFV −675.302, recovering NONMEM’s MLE;two_cpt_oral_covOFV −1197.23 ≈ nlmixr2’s −1199.24).- Documentation now builds as a Quarto website using the shared ferx site branding and styling instead of mdBook. Source pages now live under
docs/**/*.qmd, with navigation indocs/_quarto.yml(#443). - FOCE/FOCEI and SAEM/Bayes HMC gradients now come from hand-rolled analytic
Dual2sensitivities rather than Enzyme automatic differentiation. The inner EBE gradient, the outer θ/Ω/Σ gradient, and the SAEM/Bayes HMC η-sampler all use the same exact closed-form sensitivity provider; models outside its scope (ODE, LTBS, expression scaling, time-varying covariates, SDE) fall back to finite differences. The HMC sampler (saem_n_leapfrog > 0) no longer requires an autodiff build — it matches the FOCEI point estimate on warfarin with R̂ ≈ 1.00 (#367).
Removed
- The Enzyme automatic-differentiation path is retired — the
ad/module, theautodiffCargo feature, and the customenzymetoolchain pin are removed. ferx-core now builds on a stock nightly toolchain withcargo build(no from-source compiler, noRUSTFLAGS="-Z autodiff=Enable").gradient_method = adnow returns anE_AD_RETIREDerror; usegradient = auto(the exact analytic gradient where it is in scope, finite differences otherwise) orgradient = fd(#367).
Fixed
- The
autooptimizer now selects the derivative-free Bobyqa for time-to-event ([event_model]) objectives, which are finite-difference-only. The shared analytic-outer-gradient predicate previously reported a gradient for TTE (and mixed PK+TTE) models that the sensitivity provider cannot supply, soautoresolved to a gradient-based optimizer that stalled at the initial estimates; TTE fits with the default optimizer now converge (#490). [simulation]block now honours the documentedn_subjects/dose_amt/dose_cmtkeys. The parser previously only recognised the shortsubjects/dose/cmtspellings and silently ignored every other key, so allexamples/*.ferx(which use the long forms) fell back to the defaults (10 subjects, dose 100, compartment 1) — e.g.n_subjects = 12simulated 10. Both spellings are now accepted (long forms canonical, short forms as aliases), and an unknown or malformed key in[simulation]is now a hard parse error instead of a silent default, matching[fit_options].- The ODE-solver fit options
ode_reltol,ode_abstol, andode_max_stepsno longer emit a spurious “is not used by method … and will be ignored” warning (#516). They configure the RK45 integrator and are applied to any ODE model under every estimation method; they were simply missing from the warning’s framework-key allowlist. Behaviour is unchanged — only the misleading warning is removed. - Simulation, NPDE/NPD diagnostics, and the NCA-init grid sweep now honour time-varying covariate snapshots on dose, observation, and EVID=2 rows instead of using only each subject’s baseline covariates (#506). FREM covariate pseudo-observations keep their additive
EPSCOVerror in simulation/NPDE rather than being fed through the PK residual-error model. - TTE simulation now applies administrative right-censoring (#440).
simulate()for a[event_model](TTE) endpoint previously emitted every drawn event time as an uncensored event, so simulated data could not reproduce a study’s censoring pattern (which broke simulation-estimation validation). A subject’s administrative observation horizon is now honoured: a draw that reaches it is recorded as right-censored at the horizon (observed = false). The horizon is theObsRecord::Eventtime of a right-censored record; an exact-event (or interval-censored) record carries no horizon — itstimeis the event time, not a censoring window — so it draws uncensored rather than being truncated at the realized event time (which would bias re-simulation / VPC). Left-truncated (delayed-entry) subjects draw conditional on survival past entry. Behind thesurvivalfeature. - Analytic sensitivities and predictions for time-varying covariates with intermediate
[individual_parameters]assignments (#455, #456). A model whose individual-parameter block computes intermediate quantities (e.g.WTREL = WT / 70) before the structural PK outputs now gets the exact analyticDual2gradient on every path — the TV-cov gate plus the previously-overlooked non-TV (subject_sensitivities/subject_eta_grad) and IOV gates all key on the required structural PK slots instead of the assignment count, so these models no longer silently fall back to a fallback that mis-seeded∂f/∂η. Additionally, the publicpredict()and the sdtabPREDcolumn now both route through the TV-covariate-aware predictor, so they honour per-event covariate breakpoints (and EVID=3/4 resets) and agree with each other. Cross-checked against NONMEM 7.5.1 (ADVAN3 TRANS4, EVID=2 covariate update). - FOCE/FOCEI analytic outer gradients stay enabled for populations that include dosing-only subjects. Such subjects contribute zero to the marginal objective, so they now return a zero analytic gradient instead of forcing SLSQP/L-BFGS onto the slower fixed-EBE fallback path (#455).
- Gradient-based optimizers no longer stall when a few subjects are declined by the analytic outer gradient (#455). The exact analytic outer gradient was assembled all-or-nothing: a single declined subject — whether structurally out of scope (steady-state + reset, modeled-duration dose, oral infusion under F≠1) or numerically declined (an indefinite per-subject inner Hessian that fails the Cholesky factor in the gradient assembly) — forced the whole population onto the θ-only fixed-EBE fallback, whose biased Ω/σ block left the variance components pinned at their start and stalled
slsqp/nlopt_lbfgs/mma/lbfgswell above the derivative-free (bobyqa) optimum. The non-IOV outer gradient is now assembled per subject — exact analytic for in-scope subjects, a reconverged per-subject finite-difference (carrying the full η̂/Ω/σ EBE response, no PD Hessian required) for the declined ones — so one declined subject no longer disables the exact gradient for the other thousands. On the 5937-subject pediatric Jasmine fit (one subject with an indefinite inner Hessian), default- start FOCEIslsqpimproves from the previous stalled best OFV 73468 to 66593, whilemmareaches 66560.68 best-seen — about 21 OFV above the NONMEM reference (66539.38) and below bothbobyqa(68456 best-seen) and SAEM 500/500 (67377). - Documentation no longer references the retired Enzyme/autodiff installation or usage path, and now describes
gradient = auto/gradient = fdwith the analyticDual2sensitivity provider (#381). - SAEM/Bayes HMC step-size adaptation targeted the random-walk acceptance rate (≈0.234) for the gradient-guided HMC η-kernel, which over-inflated the leapfrog step until trajectories diverged — over-dispersing η and biasing the residual error (a warfarin Bayes-HMC run gave
PROP_ERR≈ 0.05 / R̂ > 2 vs the correct ≈ 0.011). The HMC kernel now adapts toward ≈0.7, matching the SAEM split (#367). - Overlapping steady-state infusions (
T_inf > II) are now solved exactly for the analytical 1-/2-/3-compartment models instead of being skipped. Previously the closed form returned 0 and the dose was applied as a single (non-SS) infusion (with aW_STEADY_STATE_INFUSIONwarning); the steady-state concentration now superposes the infinite past pulse train (several pulses simultaneously active), validated against explicit superposition. The analytic FOCE/FOCEI sensitivity provider carries the same closed form, so these subjects no longer fall back to finite differences. The warning now fires only for model paths that still skip SS pre-equilibration (ODE models, or EVID=3/4 resets) (#379).
Performance
- Faster outer-gradient sensitivities for user-
[odes]models with IIV-free parameters (#445). The augmented-Dual2RK45 now carries a second-order Hessian only over the individual parameters that bear IIV (η), dropping the block among the IIV-free (θ-only) parameters — which the FOCEI gradient never reads, since it uses no∂²f/∂θ². On a 2-compartment ODE with 2 of 4 individual parameters fixed, the per-subject sensitivity cost falls ≈2.2×; the retained dual entries and the first-order chain (df_deta,df_dtheta) are bit-for-bit, and the chained second-order outputs (d2f_deta2,d2f_deta_dtheta) agree to ~1e-9 (the terms are identical but summed in a different order). Models whose individual parameters all carry IIV are unaffected.
Added
- Analytic sensitivities for dose lagtime (ALAG) on analytical PK models: a declared
LAGTIME/alagparameter is now differentiated exactly by the sensitivity provider — it enters every dose through the elapsed-time argument (∂elapsed/∂lagtime = −1, seeded as its own dual axis), including the steady-state pre-arrival tail. Lagtime models therefore drive the analytic FOCE/FOCEI outer gradient and the analytic inner EBE gradient instead of falling back to finite differences. Validated against finite differences of the production predictor (value, ∂/∂η, ∂²/∂η², ∂/∂θ, ∂²/∂η∂θ) and as a full packed outer gradient (#367). - Analytic M3 (BLOQ) outer gradient for both FOCE and FOCEI on analytical PK models: the exact closed-form marginal gradient now covers M3-censored subjects. Under FOCEI a censored row enters the Almquist Laplace assembly as a data term
−logΦ((LLOQ−f)/√V)plus its true-inner-Hessian curvature, excluded fromH̃/log|H̃|. Under FOCE it leaves the Sheiner–Beal marginal (R̃and the quadratic form are built over the quantified rows only) and re-enters as−logΦ((LLOQ−f̂)/√R⁰)with the population variance. Both match ferx’s M3 objective and are validated against reconverged finite differences (~1e-6 on every θ/Ω/σ packed parameter) and against NONMEM (METHOD=1 LAPLACEwith and without INTER) to <1% on the structural parameters (#367). - Analytic M3 (BLOQ) inner EBE gradient for analytical PK models: the per-subject EBE optimiser now has an exact closed-form η-gradient for the M3 censored term
−logΦ((LLOQ−f)/√V)(inverse-Mills-ratio coefficient), replacing the finite-difference inner gradient onbloq_method = m3fits (#367). - Analytic FOCE and FOCEI outer gradient for analytical 1-/2-/3-compartment models (IV bolus/infusion, oral, and steady state): the gradient-based outer optimizers (
bfgs,lbfgs,nlopt_lbfgs,slsqp) now drive both FOCEI and FOCE with an exact closed-form marginal gradient (Almquist et al. 2015), evaluated through hand-rolled second-order dual numbers — no finite differences and no Enzyme. FOCEI differentiates the Laplace marginal (Eq. 23); FOCE differentiates ferx’s Sheiner–Beal linearized marginal — both carry the exact EBE response (Eq. 46) on every θ/Ω/σ block, share an exact inner-loop Jacobian, and use an EBE warm-start predictor (Eq. 48). Estimates and OFV are unchanged, but the gradient is exact: it carries the EBE response in closed form, solbfgs/nlopt_lbfgsreach the true optimum where the previous fixed-EBE FD gradient stalls short (warfarin FOCEI: −286.00 vs −281.83) — and do so ~13× faster than the only FD setting that also converges (reconverge_gradient_interval = 1: 0.30 s vs 4.11 s). Validated against NONMEM on warfarin (FOCE OFV −280.36, FOCEI −286.00 — both matching to ~4–5 significant figures). Models outside the analytical scope (ODE models, steady-state edges) transparently fall back to the existing finite-difference gradient (#367). - Analytic FOCE/FOCEI outer gradient for time-varying covariates on the analytical 1-/2-/3-compartment models. A covariate that changes within a subject (e.g. an allometric
(WT/70)^θon CL with a time-varying weight) makes the PK parameters switch mid-decay, which dose superposition cannot express; these subjects now route through the second-order-dual event-driven walk, with each event’s PK-parameter derivatives evaluated at that event’s covariate snapshot. The walk handles covariate breakpoints carried by EVID=2 records between observations, combined with EVID 3/4 resets, with steady-state dosing (each occasion’s SS state is equilibrated at the dose’s covariate snapshot), with a constantobs_scaledivisor, and with inter-occasion variability (IOV) (the covariate and κ both switch the individual parameters across occasions). The result is the standard(η, θ)jet, so the exact θ/Ω/σ packed gradient (incl. the covariate coefficients and the EBE response) is assembled unchanged. Validated against reconverged finite differences (~1e-6 on every packed parameter, FOCEI and FOCE), against finite differences of the production predictor across 1-/2-/3-cpt (incl. SS, the constant scale, and the IOV+covariate merge with an EVID=2 breakpoint), and end-to-end on a simulated WT-on-CL dataset. Requires a gradient-based outer optimizer (lbfgs/bfgs/slsqp); the analytic inner EBE gradient still uses finite differences for these subjects. Time-varying covariates combined with dose lagtime or with expression-based output scaling (obs_scale = <expr>referencing parameters/covariates) still fall back to the finite-difference gradient (#367). - Analytic FOCE/FOCEI outer gradient for inter-occasion variability (IOV) on the analytical 1-/2-/3-compartment models. The exact closed-form marginal gradient now covers κ (kappa) random effects: the EBE response, inner Jacobian, and θ/Ω/σ packed blocks are assembled over the stacked random-effects vector
[η_bsv, κ_occasion₁, …, κ_occasion_K]with the block-diagonal priorΩ_bsv ⊕ K·Ω_iov(the shared per-occasion κ-variance). Cross-occasion carryover is differentiated exactly through a second-order-dual event-driven walk (no superposition approximation, no finite differences). EVID 3/4 resets / washout occasions are supported on the IOV path as well: the walk zeros the state at each reset and rebuilds the following occasion. Validated against reconverged finite differences (~1e-6 on every packed parameter, FOCEI and FOCE) and against NONMEM on the warfarin IOV model (FOCEI OFV 307.8 vs 308.8, structural parameters within ~1%). Requires a gradient-based outer optimizer (lbfgs/bfgs/slsqp); IOV fits with steady-state doses still fall back to finite differences (#367). - Analytic gradient now covers log-transform-both-sides (LTBS) and constant output scaling for the analytical PK models: the sensitivity provider applies the
g = ln(f)jet transform (value, gradient, and Hessian via∂²g/∂x∂y = f_xy/f − f_x·f_y/f²) and the constantobs_scaledivisor in closed form, solog(DV) ~ additive(...)and[scaling] obs_scale = kfits run on the exact analytic FOCE/FOCEI gradient instead of falling back to finite differences. Validated against NONMEM on the warfarin LTBS model: the gradient-based L-BFGS path reaches OFV −675.302 and recovers NONMEM’s MLE to ~4 significant figures (#367). inner_optimizerfit option (auto|bfgs|lbfgs|nelder_mead) to pin the inner EBE optimizer explicitly.auto(default) preserves the prior behaviour (dense BFGS, switching to L-BFGS above 32 random effects); the other values force a single algorithm with no automatic switching (#367).- Analytic FOCE/FOCEI gradient for user-specified
[odes]models (issue #367, Option A): the same exact closed-form marginal gradient now covers hand-written ODE models, not just the analytical PK solutions. The compiled[odes]RHS is evaluated over hand-rolled second-order dual numbers through a generic bytecode VM, and a dual-state RK45 (value-based step control) propagates the exact PK-parameter sensitivities through the integration — no Enzyme, no finite differences of the integrator. Supported scope: IV bolus and infusion doses, bioavailability F (including estimated, any parameterization — log-normal, logit-normal, additive),obs_cmtor simple Form C (y = central/V1) readouts, static covariates, EVID 3/4 resets / multi-occasion, non-zeroinit(...)initial conditions, and up to 12 individual parameters. Models outside this scope (steady-state dosing, lagtime, built-in input-rate absorption, IOV, SDE,obs_scale/LTBS transforms, time-varying covariates) transparently fall back to the finite-difference gradient (#367). - Modeled infusion rate (
RATE=-1→R{cmt}) — NONMEM’s codedRATE=-1now makes the infusion rate a$PK-style individual parameterR{cmt}(duration =AMT/R{cmt}), the mirror of the modeled-durationRATE=-2/D{cmt}support. Works on both the analyticalpk(...)engine andode(...)models; resolves per iteration/occasion and composes withF/lag/SS. ARATE=-1dose with no matchingR{cmt}is a loudE_MODELED_RATE_NO_PARAMerror (never a silent bolus), and a non-positiveR{cmt}at the initial estimate warns (W_MODELED_RATE_NONPOSITIVE). This completes NONMEM coded-RATEsupport (#324). Under bioavailabilityF ≠ 1it holds the rate and scales the duration toF·AMT/R{cmt}, matching NONMEM for rate-defined infusions (#419, see Changed). - M3 likelihood now supports above-LOQ/right-censored observations via
CENS=-1, withDVcarrying the ULOQ value (#297). ACENSvalue other than-1,0, or1now raises aW_CENS_UNEXPECTEDdata warning instead of being silently scored as censored. imp_auto/impmap_autofit options (NONMEMAUTO), on by default: adaptive importance-sample count.imp_samples/impmap_samplesis the starting count and is ramped up (×2 per iteration, capped at 10000) whenever the objective’s Monte-Carlo standard deviation exceeds 1.0 (NONMEMSTDOBJ), so high-dimensional / FREM fits reach a low-noise objective automatically instead of carrying a sample-count-dependent M-step bias. On the FREM workshop model (13 ETAs) this ramps 300→10000 and brings the absorption typical value from ~4.6 (fixed K=300) to ~3.0, matching NONMEM. Low-dimensional, well-sampled fits never trip the threshold, so there is no cost there; setfalseto pin the sample count (#411).- IMP/IMPMAP now warn when the importance-sample count is low for the model dimension (
K < 100·n_eta) or when a subject’s proposal fully collapses (ESS ≈ 0). The self-normalized M-step moments carry a finite-sample bias that grows with dimension, so high-dimensional / FREM fits at the default sample count can converge to biased typical-value and Ω estimates; the warning recommends raisingimpmap_samples/imp_samples(#411). frem_rao_blackwellfit option (defaulttrue): toggle the Rao-Blackwellised FREM covariate-ETA integration in IMP/IMPMAP. Setfalseonly to diagnose the RB path against the full-dimensional importance sampler (#406).- IIV on residual error (
iiv_on_ruv) — a random effect can now scale the residual error per subject (NONMEMY = IPRED + EPS*EXP(ETA)). Declare anomegaand reference it from[error_model]withiiv_on_ruv = NAME; the residual variance of every observation is multiplied byexp(2*ETA_i). Supported under FOCEI, IMP, IMPMAP, and SAEM (non-interaction FOCE is rejected with a clear error). Previously such a random effect was silently dropped on import (#409). - Covariance step progress reporting — under
verbose, the covariance step now prints throttled per-loop progress (Hessian finite-difference points and the score cross-product) with a wall-clock ETA, e.g.[covariance] Hessian 12/40 (~8s left), so long covariance computations are no longer silent. - Cancellable covariance step — a
CancelFlagtripped during the covariance step (not just before it) now cooperatively aborts the finite-difference Hessian and score-matrix loops and finishes the fit without standard errors (recording a warning), instead of running the cancelled work to completion. impmap_mcetafit option: multi-start MAP for IMPMAP (NONMEMMCETAequivalent), improving IS efficiency in high-dimensional models (e.g. FREM with ≥5 ETAs).- Analytical Jacobian for FREM pseudo-observations: covariate rows in the FD Jacobian are overwritten with exact ∂Y/∂η values (0 or 1), eliminating noise that corrupted the IS proposal in high-dimensional FREM models.
iscale_min/iscale_maxfit options: adaptive IS proposal scaling (NONMEMISCALE_MIN/ISCALE_MAXequivalent). Per-subject pilot search over log-spaced scale factors selects the proposal width that maximises ESS. Defaults: 0.1–10.0.impmap_sobolfit option: use Sobol quasi-random sequences (with Cranley-Patterson randomization) for IMPMAP IS draws instead of pseudo-random, giving more uniform coverage of the posterior. MVN proposals only; Student-t falls back to pseudo-random.- Full off-diagonal omega standard errors for block omega via multivariate delta method on the Cholesky parameterization.
se_omegais now the full lower triangle (length n_eta*(n_eta+1)/2) instead of diagonal-only. Addedomega_se_at()helper for indexed lookup. - Per-iteration IMPMAP parameter trace (
FitResult.impmap_trace), analogous to NONMEM.extfile output. Opt-in viaimpmap_trace = truein[fit_options]. - FREM (Full Random Effects Model) covariate analysis:
prepare_frem()API transforms a base model + dataset into a FREM model with extended block omega, covariate pseudo-observations, and FREMTYPE dispatch in the likelihood. The covariates (and their continuous/categorical kind) are taken from the model’s[covariates]block; thecovariatesargument is an optional subset filter over them (#194). - Zero-order absorption into the oral depot on analytical models — a
RATE=-2modeled durationD1(or an explicit positive-RATEinfusion) into compartment 1 of an analytical oral model (one_cpt_oral/two_cpt_oral/three_cpt_oral) now models zero-order release into the depot followed by first-orderKAabsorption into central, all on the closed-form engine — noode(...)block needed (previously rejected at parse time). Validated against NONMEM 7.5.1ADVAN2($PK D1) and against the ODE transcription across 1-/2-/3-cpt oral models. Per-compartment amounts insdtab/[derived]are not available for those subjects (predictions are exact; aW_DERIVED_CMT_ORAL_DEPOT_INFUSION_ANALYTICALwarning flags it) (#400). RATE=-2(modeled infusion duration via aD{cmt}parameter) is now supported on analytical PK models, not just ODE models — declare aD{cmt}individual parameter and the closed-form infusion usesrate = AMT / D{cmt}, matching NONMEM’s$PK D{n}(#394, follow-up to #324).- Full MCMC Bayesian estimation (
method = bayes, Gibbs-within-HMC, NONMEMMETHOD=BAYESparity). Draws from the joint posteriorp(θ, Ω, Σ, {ηᵢ} | y): per-subject η block (block-MH, or gradient HMC on the analyticDual2gradient withn_leapfrog > 0), conjugate inverse-Wishart Ω block, exact Gaussian full-conditional draw for mu-referenced θ, and a random-walk block for the remaining θ/σ. Reports posterior summaries (mean/sd/2.5%/median/97.5%) with split-R̂, ESS, and MCSE per parameter onFitResult.bayesand in the.fit.yamlbayes:section. Options:bayes_warmup,bayes_iters,bayes_chains,bayes_thin,bayes_seed. Supports BSV and zero-mean IOV (per-occasionkappa, with a conjugate inverse-WishartOmega_iovdraw). Validated against FOCEI and NONMEMMETHOD=BAYESon warfarin (#380). - Modeled infusion duration (
RATE=-2→Dn) for ODE models — NONMEM’sRATE=-2makes a zero-order infusion’s duration a modeled parameter: name an individual parameterD{n}for the dose compartmentnand ferx infusesAMTover that duration (rateAMT/Dn), resolved per iteration and occasion (so it can carry covariate effects and IOV). Composes withF{n}(applied exactly once —F·AMToverDn) andALAG{n}(shifts the window;Dnsets its length), and works with steady state, multi-dose, and system resets. ARATE=-2dose with no matchingD{n}parameter — or on an analytical model — is now a loud error rather than a silent bolus (the original #324 bug), both at the model+data join (fit/ferx check) and at thepredict()/simulate()entrypoints (which skip the full data-check). A modeledD{n}that is non-positive at the initial estimate is flagged with aW_MODELED_DURATION_NONPOSITIVEwarning (use a positive link such asexp).RATE=-1(modeled rate,Rn) and analytical-engine support remain tracked #324 follow-ups (#324). - Simulation-based NPDE / NPD diagnostics in the
sdtaboutput. Set[fit_options] npde_nsim = 1000(and optionallynpde_seed) to addNPDE(Normalized Prediction Distribution Errors, decorrelated within subject) andNPD(Normalized Prediction Discrepancies) columns, computed post-fit by Monte-Carlo simulation under the fitted model (Brendel et al. 2006; Comets et al. 2008). Unlike CWRES, these are robust to model nonlinearity and non-Gaussian random effects, and follow N(0,1) under a correctly specified model. Off by default (npde_nsim = 0). The effective simulation seed (including the default whennpde_seedis unset) is recorded asnpde_seedin{model}-fit.yamland the.fitrxarchive, so the diagnostics are reproducible from the saved fit. Validated against a NONMEM$SIMULATION+npdeR-package reference on the warfarin example. M3/BLQ censoring and IOV-kappa resampling are out of scope (#260). - Compartment-indexed bioavailability and lag for ODE models — name an individual parameter
F{n}orALAG{n}/LAGTIME{n}(e.g.F2,ALAG2) to apply a per-route bioavailability/lag to doses into compartmentn, mirroring NONMEM’sF1/F2/ALAG1/ALAG2. A bareF/lagtimestays the all-compartment default (existing single-route models are unchanged); an indexed value overrides only its compartment. Resolved uniformly across every ODE dose-application path (event-driven, steady-state, and the EKF/diffusion path — the latter appliesFbut not lag). An index past the model’s compartment count is a parse error rather than a silently-ignored parameter. Foundation for the modeled-duration/rate (Dn/Rn) work in #324 (#369). ode_template NAME(...)in[structural_model]generates the standard disposition ODE for a named model (one/two/three_cpt_iv|oral) from the same closed-form↔︎ODE transcription the analyticalpk NAME(...)uses — so you get the explicit, runnable ODE form without hand-writing the states, RHS, andobs_scale. It takes the same parameters aspk NAME(...)(includingkafor oral routes). Re-declaring ad/dt(X)in[odes]overrides the generated equation for compartmentX(e.g. to add atransit(...)absorption input); undeclared compartments keep their generated equations. Combining the ODE-onlytransit(...)absorption with an analyticalpk NAME(...)is now a clear error pointing atode_template, never a silent analytical→ODE conversion. (Future ODE-only absorption functions join that error rule as each is implemented.) (#322).- Built-in transit-compartment absorption for ODE models via a
transit(n, mtt)input-rate function in the[odes]block (Savic et al. 2007, continuousn):R_in(tad) = F·Dose·KTR·(KTR·tad)^n·e^(−KTR·tad)/Γ(n+1),KTR=(n+1)/mtt. The dose is delivered as this appearance rate into the depot (∫R_in dt = F·Dose) — not also as a bolus — so a flexible, continuously-estimable absorption shape takes one line instead of a hand-coded transit chain. HonorsF/lagtime and superposes over doses; works with IIV/IOV, resets, and time-varying covariates. Unsupported combinations are rejected with a clear error rather than silently mis-modeled: steady-state dosing into a transit compartment (E_ABSORPTION_SS), an infusion (RATE>0) into a transit compartment (E_ABSORPTION_RATE, which would double-count the dose), a[diffusion]block together withtransit()(E_ABSORPTION_DIFFUSION), and an out-of-domainmtt/nat typical values (E_ABSORPTION_DOMAIN). New exampleexamples/transit_savic.ferxand docs page Built-in Absorption Models (#322). - Built-in inverse-Gaussian (Freijer & Post) absorption for ODE models via an
igd(mat, cv2)input-rate function in the[odes]block:R_in(tad) = F·Dose·√(MAT/(2π·CV2·tad³))·exp(−(tad−MAT)²/(2·CV2·MAT·tad)), the inverse-Gaussian density with mean absorption timeMATand relative dispersionCV2(shapeλ = MAT/CV2). Models the entire absorption delay and feeds the central compartment directly (no first-orderka);∫R_in dt = F·Dose. Reuses the same dose routing,F/lagtime, superposition, IOV, domain validation (mat>0,cv2>0), and unsupported-combination guards astransit(); the essential singularity attad→0is handled (R_in→0). NONMEM-anchored against a$DESIG run (nonmem_anchor/freijer_ig.ctl). New exampleexamples/igd_inverse_gaussian.ferx. The biphasic Freijer sum-of-two is a planned follow-up (#347, #388). - Example
dose_rate.ferx(+data/dose_rate.csv) demonstrating the supported NONMEMRATEdosing forms — a bolus (RATE=0) and a constant-rate infusion (RATE>0) mixed in one dataset (#324). - Configurable RK45 ODE solver tolerances via
[fit_options](and call-time settings):ode_reltol(default1e-4),ode_abstol(default1e-6), andode_max_steps(default10000). Defaults are unchanged, so existing fits are unaffected. Previously the tolerance was hardcoded, which made the OFV of an ODE-form model differ from its analytical equivalent by several units (the FOCE objective amplifies the ~1e-4solver error); a tighterode_reltolnow lets the two forms agree. Carried onOdeSpec::solver_optsand applied viaCompiledModel::sync_ode_solver_opts(#127). parameter_scalingfit option (none/abs/rescale2): parameter scaling for the outer optimizer.rescale2is the nlmixr2-style bound-half-width normalisation (maps each packed parameter toward(−1, 1)) and substantially improves cold-start convergence for gradient-based optimizers on ill-conditioned multi-parameter surfaces — e.g.bfgsreaches OFV −1198.97 ontwo_cpt_oral_cov(≈ nlmixr2’s −1199.24) where the unscaled optimizer stalls near −1192. The defaultautoappliesrescale2to the gradient-based optimizers (bfgs/lbfgs/nlopt_lbfgs/slsqp) and leaves the derivative-freebobyqaunscaled (whererescale2distorts its trust region) (#341).covariance_ofv_hessianfit option: build the covariance R-matrix from second differences of the reconverged marginal OFV instead of a central difference of the analytical population gradient. The analytical stencil holds the H-matrixa = ∂f/∂ηfixed in thelog|H̃|θ-gradient, biasing the SE of weakly-identified structural parameters (e.g. warfarin TVKA reads ~9% high versus a Richardson FD-of-OFV ground truth); the OFV-Hessian stencil recomputesaat every perturbed point and matches the ground truth to <1%, at ≈ the same wall-clock cost (both stencils parallelise over perturbation points). Defaulttrue; setfalseto force the faster analytical-gradient stencil (#335).- Propensity-score-matched simulation:
simulate_with_options()with a newSimulateOptions { seed, match_method }. Whenmatch_methodisSome(..), each replicate’s drawn etas are reassigned to subjects by Mahalanobis matching (under the model Ω) against the subjects’ fitted (posthoc) etas, so a subject’s observed dosing/sampling design is paired with a similar drawn eta. This corrects VPC bias from treatment adaptation in real-world data (longer intervals for high-clearance patients, etc.). Three methods are offered viaMatchMethod:Optimal(global linear-assignment minimum; best on average in simulation, recommended default),Nearest(greedy nearest-neighbour,MatchIt(method="nearest", distance="mahalanobis")), andRank(pair by the rank of the Mahalanobis norm). Operates on observed data; returns the usual simulation rows for the caller to build the VPC (#288, #396). - New
importance_sampling_map(aliasimpmap) estimation method: a Monte-Carlo EM estimator equivalent to NONMEMMETHOD=IMPMAP. Each iteration re-centers a per-subject importance-sampling proposal on the conditional mode (MAP) and updates θ/Ω/σ from the importance-weighted posterior moments. Runs standalone or chained (methods = [focei, impmap]); multivariate-normal proposal by default (impmap_proposal_df = normal), Student-t optional. Validated against FOCEI on warfarin. IOV and SDE models are not yet supported (#270). - Importance sampling can now run standalone (
method = imp), evaluating the IS log-likelihood at the initial parameters — IMP derives the EBEs/Jacobian it needs via a FOCE inner loop at those parameters instead of requiring a preceding estimator. Useful for scoring imported/fixed parameter sets. IMP still may appear at most once and must be the terminal stage of a chain. - SAEM conditional-distribution pass: set
conddist = truein[fit_options]to estimate each subject’s conditional distribution of the random effectsp(η_i | y_i)by MCMC after the fit — reporting per-subject conditional mean, SD, distribution-based η-shrinkage, and (withconddist_keep_samples = true) the raw draws. Surfaced onFitResult.cond_distand written to{model}-conddist.csv(+-conddist-samples.csv). This is the SAEM analogue of saemixconddist.saemix/ Monolix’s “Conditional Distribution” task and is the shrinkage-unbiased basis for η diagnostics; validated against saemix on warfarin (#257). - Feature maturity labels (
stable/beta/experimental) documented for every major feature: a new Feature Maturity docs page with definitions and a per-feature table, plus a maturity banner on each feature reference page. Experimental features ([diffusion]/ SDE,[covariate_nn]/ neural networks) now emit a runtime warning at fit time (W_EXPERIMENTAL_SDE,W_EXPERIMENTAL_NN), also surfaced byferx check(#175). covariance_methodfit option: choose the covariance estimator, mirroring NONMEM$COV MATRIX=—r(inverse HessianR⁻¹, default),s(inverse score cross-productS⁻¹), orrsr(the Huber–White sandwichR⁻¹SR⁻¹, robust to model mis-specification). Supported for FOCEI, FOCE, and IOV fits; anchored against NONMEM$COV MATRIX=S/RSRwithin ~10% for both FOCEI (#266) and FOCE (#250) (#223).covariance_fallback = sirfit option: when the FD Hessian is non-positive-definite, run SIR with an|eigenvalue|-rectified proposal (4× inflated) instead of leaving the covariance step as failed;covariance_statusreportssir_fallback(#223).covariance_matrix:block in*-fit.yaml: the full optimizer-space parameter covariance matrix (log-theta, Cholesky-omega, log-sigma; kappa appended for IOV models), parameter-labelled, emitted when the covariance step succeeds or is regularised. Omega/kappa diagonal entries are keyedlog_chol_<eta>(packed value islog(L_ii)); off-diagonal entries are keyedchol_<row>_<col>(L_ij, not log-transformed) (#236).- Time-to-event / survival modelling (Phase 1):
[event_model]block, TTE datareader, likelihood, and API wiring, behind thesurvivalfeature (#191, #192). [data_selection]block with NONMEM-styleIGNORE/ACCEPTrecord filtering, plus anExclusionSummaryonFitResultsurfaced in the CLI and YAML output.- Combined ferx-core + ferx-r development documentation: a Development Lifecycle (SDLC) page and a Contributing page in the book.
[structural_model]now warns when apk(...)line maps a parameter the chosen model does not use (e.g.kaorfon an IV model, orq/v2on a one-compartment model); the mapping is accepted but has no effect (#309).[individual_parameters]now warns when a declared parameter is computed but never used — neither mapped into thepk(...)model nor referenced in any other block (e.g. declaringFbut forgettingf=F); it silently has no effect (#309).MACHEPS(machine epsilon) is now available in[odes]RHS andinit(...)expressions, matching its existing availability in[derived](#314).- The “computed but never used” warning above now also covers ODE models: an
[individual_parameters]entry never referenced in the[odes]right-hand side (nor in[scaling]/[derived]/[output]) is flagged the same way. The engine-appliedF(bioavailability) andlagtime(aliasalag), which act on the dose without appearing in the RHS, are exempt (#315).
Changed
- Bioavailability
Fnow reshapes a rate-defined infusion the NONMEM way (RATE>0data andRATE=-1→R{cmt}):Fholds the rate and scales the duration toF·AMT/RATE, instead of scaling the rate over a fixed duration. A duration-defined infusion (RATE=-2→D{cmt}) is unchanged —Fstill scales its rate. Total exposure (F·AMT) is unchanged in both cases; only the infusion shape changes, and only for an existingRATE>0/RATE=-1infusion withF ≠ 1. Predictions, simulations, and fits for such models will differ; models withF = 1, bolus, oral-depot, orRATE=-2dosing are unaffected. This aligns all engines (analytical superposition, event-driven, ODE, analytic sensitivities) with NONMEM’sRATE/Fconvention (#419, follow-up to #327/#324). method = focewith M3 BLOQ no longer promotes censored subjects to FOCEI. Previously a subject with anyCENS=1row was silently evaluated with η-interaction (mixing a Sheiner–Beal FOCE objective with a FOCEI censored term). Plain FOCE now keeps a consistent Sheiner–Beal objective for the whole subject, with censored rows entering as−logΦ((LLOQ−f̂)/√R⁰)(population variance, excluded fromR̃). FOCE-M3 and FOCEI-M3 are genuinely different optima — on warfarin BLOQ, FOCE TVKA ≈ 0.71 vs FOCEI ≈ 0.81, each matching the corresponding NONMEMMETHOD=1 LAPLACE(with/without INTER) fit. M3 fits that relied on the old auto-promotion should setmethod = foceiexplicitly (#367).- Bumped
nalgebrato 0.35 (from 0.34). Theargmin-mathdependency now uses itsvecfeature instead ofnalgebra_latest, since the argmin trust-region path operates onVecparams and never onnalgebratypes — this avoids pulling a second, conflictingnalgebraversion into the graph. Downstream Rust consumers (e.g.ferx-r) must move tonalgebra0.35 in lockstep. - IMP fit options now use the
imp_*prefix (imp_samples,imp_eval_only,imp_auto, etc.) instead of the olderis_*names. The old names are not retained as aliases because IMP support is still new. - SAEM no longer automatically runs a FOCEI polish when a combined-error additive sigma collapses; it now leaves the SAEM estimate unchanged and records a warning that the additive component hit its lower bound (#420).
- IMPMAP default proposal is now a Student-t (
impmap_proposal_df = 4) instead of a multivariate normal. A Gaussian proposal’s tails are lighter than the posterior of weakly-identified parameters, so importance weights blow up in the tail and bias the M-step moments — drifting typical-value estimates (e.g. the absorptionMAT/KAon modeled-duration models). The heavier-tailed default removes that bias and matches FOCEI/NONMEM. Setimpmap_proposal_df = normalfor the previous behaviour (#411). - IMP/IMPMAP now warn about estimated parameters with no random effect: any non-fixed
thetathat has no associatedETAis estimated only through the importance-weighted M-step, which is biased for weakly-identified parameters and can converge to the wrong value (e.g. a FREM absorption fraction drifting to ~0.9 vs a FOCEI/NONMEM value of ~0.4). The estimator now emits a strong warning naming such parameters and recommending anETAbe added (ferx mu-references automatically), the parameter be heldFIX, or FOCEI be used.prepare_frem(ferx_to_frem) also surfaces this advisory at conversion time via a newFremPrepareResult.warningsfield, so it shows up before fitting. (#406) - IMP/IMPMAP now Rao-Blackwellise FREM covariate ETAs: the Gaussian covariate pseudo-observation ETAs are integrated analytically (conditional PK prior from the Ω precision blocks) and only the PK ETAs are importance-sampled. This turns the high-dimensional, multi-scale IS (≈1–2% effective sample size, unstable M-step) into a well-conditioned low-dimensional one: on the workshop 12-ETA FREM the share of low-ESS subjects dropped from ~80% to ~23%, the −2logL trajectory is smooth (no spikes), and estimates land near NONMEM (TVCL 6.7 vs 6.97, TVMAT 2.8 vs 2.75). Automatic for FREM models; falls back to full-dimensional IS if the PK/covariate partition is degenerate. (#406)
impis now a Monte-Carlo EM estimator by default (NONMEMMETHOD=IMPparity):method = impupdates θ/Ω/σ instead of only evaluating the marginal−2 log L. Breaking: model files that usedimp(e.g.[focei, imp]) purely to score a fit now re-estimate. Addimp_eval_only = true(NONMEMEONLY=1) to recover the previous evaluation-at-fixed-parameters behaviour. New optionsimp_iterations(default 200) andimp_averaging(default 50) control the MCEM loop;imp_proposal_dfnow also acceptsnormal/mvn. The estimatingimpmay lead or sit mid-chain; the evaluation-onlyimpmust still be terminal. Plainimpre-centers its proposal from the previous iteration’s sample moments and so is fragile on rich data (warm-start with[focei, imp], or useimpmap); validated against NONMEM 7.5.1METHOD=IMPon warfarin (#402).- The analytical
pk NAME(...)parameter list is now parsed strictly: a malformedrole=VARpair (no=, an empty side, or a stray extra=) or a duplicate role is a clear parse error instead of being silently dropped or last-winning. Thepkandode_template NAME(...)directives share one strict parser, so they can’t drift in strictness. Well-formed model files (including a tolerated trailing comma) are unaffected (#363). - FOCEI gradient-based optimizers (SLSQP, L-BFGS, built-in BFGS, Gauss-Newton) now add the
log|H̃|EBE-response term (the #274/#289 Δ) to the population gradient, so they reach the true marginal minimum instead of stalling above it on the fixed-EBE gradient (e.g. warfarin FOCEI −282.8 → −286.0, matching the derivative-free BOBYQA default). The term reuses the Laplace intermediates the gradient already forms (one extran_eta×n_etasolve per subject) and is zero for additive error; the BOBYQA default is unaffected (it uses no gradient). The ω-block of the correction remains deferred (#335) (#330). - The default inner (per-subject EBE) convergence tolerance
inner_tolis now1e-5(was1e-4). A looser inner tolerance left residual noise in each subject’s EBE solution that propagated into the marginal objective, causing the derivative-free BOBYQA outer optimizer to false-converge above the true minimum on noisy-marginal models (notably log-transform-both-sides FOCE). The tighter default matches NONMEM’s minimum at roughly 1.5× the per-fit cost; loosen it viainner_tolin[fit_options]to recover the old speed on well-conditioned fits (#330). - FOCE (non-interaction) now evaluates the residual variance at the population prediction
f(η=0)— NONMEM’sMETHOD=1(noINTER) semantics — instead of the linearizedf0 = f(η̂) − H·η̂. On nonlinear models (e.g. oral absorption) with proportional/combined error,f0could extrapolate to near-zero or negative concentrations, collapsingR(f0) = (f0·σ)²and making the marginal multimodal with an indefinite covariance Hessian (garbage SEs reported as “likely reliable”). FOCE+proportional fits now converge deterministically, reproduce NONMEM FOCE estimates/SEs (within ~3% on a 1-cpt oral benchmark), and yield a positive-definite covariance. Additive-error FOCE is unchanged (its variance isf-independent). The FOCE covariance forf-dependent error uses the reconverged-OFV second-difference Hessian (the true objective curvature) rather than the envelope-approximation analytical gradient (#319). - IMP (importance sampling) now jointly samples (η, κ) for IOV models, integrating over inter-occasion variability so the reported
−2 log Lis directly comparable to FOCE/FOCEI and NONMEMMETHOD=IMP. Previously κ was held fixed at its EBE mode, giving a partial marginal;kappa_treatmentin the fit YAML is nowmarginalizedrather thanfixed_at_mode(#186). - A
[structural_model]pk(...)line that omits a required parameter for the chosen model (e.g.kaforone_cpt_oral) is now a parse error naming the missing parameter, instead of silently defaulting that slot to0.0and fitting to a structurally broken optimum (#309).
Fixed
- M3 BLOQ fits with a gradient-based optimizer no longer stall above the true minimum. Previously the analytic outer gradient declined on censored subjects and the fixed-EBE finite-difference fallback was biased there, so on warfarin BLOQ a gradient optimizer settled at TVKA ≈ 1.10 / OFV ≈ −213.8 while the derivative-free BOBYQA reached the true TVKA ≈ 0.81 / OFV ≈ −217.2. FOCEI now has an exact closed-form M3 censored gradient (see Added), and plain FOCE with M3 forces the EBE-reconverging gradient automatically (as IOV already does), so every optimizer reaches the minimum and matches a NONMEM 7.5.1 LAPLACE M3 reference (TVCL 0.1328, TVV 7.731, TVKA 0.810, to ~4 significant figures). The
docs/src/examples/bloq.mdexpected results, which showed the stalled point, are corrected (#367). - IMPMAP warns instead of silently ignoring
impmap_sobolunder a Student-t proposal. Sobol draws apply only to the multivariate-normal proposal; with the Student-t defaultimpmap_sobol = truewas a no-op. It now emits a warning pointing toimpmap_proposal_df = normal(#406). - FREM Rao-Blackwell sampler falls back to full-dimensional IS for covariates with more than one pseudo-obs row. A time-varying or duplicated covariate row broke the closed-form covariate-likelihood cancellation in the RB marginal; such subjects now use the full-dimensional sampler, which scores every row consistently (#406).
- Adaptive-sampling (
imp_auto/impmap_auto) trigger is now per-subject. It used the total-objective Monte-Carlo SE, which grows as √N, so a large but well-sampled dataset could ramp the sample count to the cap purely from subject count. The trigger now normalizes by √N (per-subject objective SE), making it N-independent (#411). - IMP/IMPMAP no longer freeze the typical value of a mu-referenced parameter with negligible IIV: a log-mu-referenced θ (e.g.
KA = TVKA*exp(ETA_KA)) whose random effect has a tiny, oftenFIXed ω was updated only through the closed-formlog θ += mean(η)shift — which is ≈ 0 when the η carries no variance, leaving the typical value stuck at its initial value. Such parameters are now routed to the weighted-likelihood M-step (the channel that estimates σ and non-mu-ref θ), so the data can move them; a warning names any parameter routed this way. Makes the estimate init-independent (#411). - FREM IMP/IMPMAP marginal −2 log L over-counted by a 2π constant: the Rao-Blackwellised covariate-data marginal included the covariate pseudo-obs
nc·ln(2π)normalizer, which the rest of the objective (and NONMEM’s “OBJECTIVE FUNCTION WITHOUT CONSTANT”) drops. This inflated the reported FREM marginal byΣ nc·ln(2π)(≈ n_covariate_obs · ln2π) and made the Rao-Blackwell and full-dimensional importance samplers disagree on the same point. The constant is now dropped in both; the value is otherwise unchanged (it lies outside the importance weights, so estimates were never affected) (#406). - IMP/IMPMAP now report the NONMEM-comparable objective: estimating
impandimpmapruns surface the importance-sampling Monte-Carlo marginal −2 log L — the number NONMEMMETHOD=IMP/IMPMAPreports as its#OBJV— evaluated at the final estimates onFitResult.importance_sampling.minus2_log_likelihood(± MC SE). Previously this was populated only by the evaluation-only path, so the only available number was the FOCE-Laplaceofv, which matches NONMEM’s COND/FOCE OBJ rather than the IMP marginal and diverges from it on sparse / strongly nonlinear data.ofvis unchanged (still a Laplace pass, for cross-method AIC/BIC comparability) (#406). - IMP/IMPMAP no longer diverge on FREM models with missing covariates: the Rao-Blackwellised E-step previously bailed to the unstable full-dimensional importance sampler for any subject missing a covariate pseudo-observation row (the FREM data omits rows for missing covariate values — ~28% of subjects on the workshop model). Those subjects then blew the −2logL up to ~1e14 within a few iterations under
method = imp. Missing-covariate etas (which have no data) are now sampled together with the PK etas, conditioning only on the observed covariates; both IMP and IMPMAP now converge with near-zero low-ESS subjects and agree on the estimates. (#406) - FREM covariate pseudo-observations are no longer clamped to a positive prediction: the observation likelihood clamped every prediction to
≥1e-12, but a FREM covariate pseudo-obs predicts a covariate value (centered, standardized, or log-scale covariates are routinely≤0). Clamping a non-positive covariate prediction fabricated a huge residual, which corrupted the Rao-Blackwellised IS marginal/weights for affected subjects. Covariate rows now keep their (possibly negative) prediction; ordinary PK rows keep the positivity clamp. (#406) - FREM model generation dropped the
[scaling]/[odes]blocks:prepare_fremnow carries the base model’s[scaling](e.g.obs_scale) and[odes]blocks into the generated FREM model. Previously they were silently omitted, so a base model withobs_scale(NONMEMCP = A*1000/V) produced a FREM model whose predictions were mis-scaled; the estimator then compensated by collapsing a PK typical value (TVCL → ~1e-2 instead of ~7 on the workshop FREM model, now ~6.6 vs NONMEM 6.97). (#406) - IMP/IMPMAP on high-dimensional FREM: the inner EBE/MAP solver no longer returns a nonsensical joint mode on multi-scale FREM posteriors (3 PK + many covariate ETAs). The inner BFGS is now FREM-preconditioned (per-dimension initial inverse-Hessian ≈ posterior variance) and the covariate ETAs are cold-started at their data-implied mode
cov_obs − TV; the IS proposal jitter is now per-dimension instead of a single global value. Previously the mode collapsed (obs-NLL ~1e8) and standalone IMP/IMPMAP diverged (−2logL ~1e13) on ≥8-covariate FREM models; the typical-value estimates for volume and absorption now recover. (Full NONMEM parity still pending the mu-referencing θ M-step and high-dimensional IS effective-sample-size work — see #406.) (#406) - Bayesian estimation (
method = bayes) now samples the per-occasion IOVkappablock whenOMEGA_IOVis FIX-ed. Previously an all-FIXOMEGA_IOVdisabled kappa sampling entirely, so the kappas stayed pinned at their initial values (IOV effectively ignored); a fixedOMEGA_IOVstill defines the kappa prior variance, so the block is now sampled while its conjugate covariance draw remains correctly skipped (#415). - Bayesian estimation (
method = bayes) now responds to a cooperative cancellation (e.g. an R-session interrupt): the Gibbs sampler polls the cancel flag at each sweep boundary and aborts within one sweep, returningcancelled by userinstead of running every chain to completion. Previously a Bayes run could not be stopped once started (#393). - IMPMAP now responds to a cooperative cancellation (e.g. an R-session interrupt) during an iteration’s E-step, instead of only at iteration boundaries. The importance-sampling pass — the dominant per-iteration cost on large datasets — previously ran to completion before the cancel flag was checked, so a kill request could appear to hang for minutes; the E-step now polls per subject and the run aborts promptly (#273).
- An individual parameter assigned only inside symmetric
if/elsebranches in[individual_parameters](the NONMEM-styleIF (cond) CL = .../IF (!cond) CL = ...construction) on an ODE model is no longer rejected by the[odes]RHS validator as an undefined name. A name written on every branch is now promoted to a real individual parameter — getting a PK slot, being written back, and resolving in the ODE RHS — provided a downstream block ([odes],[structural_model],[scaling],[derived]) actually references it. Purely internal branch helpers stay branch-local and never consume a PK slot (#357). - The covariance-family fit options
covariance_method,covariance_fallback, andcovariance_ofv_hessianno longer emit a spurious “is not used by method<method>and will be ignored” warning. They are framework-wide covariance-step options (honoured for every estimator) but were missing from the warning’s allowlist; the options were always applied — only the warning was wrong. - A missing
DV(./NA/blank) on anEVID=0observation row withoutMDV=1is no longer silently scored asDV=0. Such rows are now treated asMDV=1(skipped) and a singleW_MISSING_DVwarning reports how many rows were skipped, surfaced in fit warnings andferx check(#258). - Bioavailability
Fis now applied to IV bolus and infusion doses on the analytical path, not just oral depot doses. The analytical superposition path (used for subjects with no time-varying covariates) previously droppedFfor IV/infusion dosing, so the same model gaveF×-different predictions for a no-TV subject versus a time-varying/IOV subject (the event-driven path appliedFcorrectly) — a silent inconsistency that biased fits and made an estimatedFa no-op on all-IV/infusion datasets.Fnow scales the bioavailable amount/rate on every route, matching NONMEM’sF1, the ODE engine, and the event-driven path. Mappingf=on an IV model is no longer warned as unused (#327). - Infusion (zero-order,
RATE>0) doses into the central compartment of an oral model are no longer silently dropped on the event-driven analytical path. The oral propagators ignored the infusion input rate, so a depot-bypass infusion produced ~0 concentration for any subject routed through the event-driven path (time-varying covariates, EVID=3/4 resets, or IOV) — while no-covariate subjects (superposition path) got the correct curve. The oral propagators now carry the central zero-order input by linear superposition, matching the superposition path and NONMEM. (Infusion into an oral depot compartment,cmt=1, remains an explicit error rather than silently bypassing the depot.) - NONMEM coded
RATEvalues (-1= modeled rate,-2= modeled duration) — and any other negative or non-finiteRATEon a dose row — are now rejected with an informative error naming the subject and time, instead of being silently treated as an IV bolus (which produced wrong predictions with no warning). Modeled rate/duration support is not yet implemented; convert such rows to an explicit positiveRATE(=AMT/duration) before importing (#324). - Cold-start FOCEI/SLSQP on IOV models now reaches the marginal minimum instead of stalling: under the default
parameter_scaling = auto,slsqpnow gets therescale2bound-half-width scaling, so pure FOCEI/SLSQP onwarfarin_iovconverges to OFV 307.84 (ω_iov ≈ 0.046) from the cold default start rather than stalling at 343.5 with ω_iov pinned at its init (#335). - FOCEI covariance score cross-product (
covariance_method = s/rsr) now carries thelog|H̃|EBE-response term (½·∂log|H̃|/∂η̂·dη̂/dθ, the #274tᵢ): the per-subject score is differenced with the conditional estimate η̂ responding to the parameters, matching how NONMEM forms its S matrix. Previously the score held η̂ fixed (the R-matrix already captured this term via reconvergence, but S did not), so the RSR sandwich SEs were biased on weakly-identified structural parameters — warfarin SE(TVKA) ~5% out. With the term, FOCEI RSR matches NONMEM 7.5.1 to <1.8% on every parameter (#335). - A
[structural_model]PK parameter that references a name not defined in[individual_parameters](e.g.pk one_cpt_oral(cl=CL, ...)with noCL) is now a parse error instead of being silently dropped and defaulting the slot to 0.0 — which previously produced a “converged” but structurally broken fit (all predictions floored, 100% shrinkage). An unrecognized PK-parameter key (e.g. the typoclx=) is likewise rejected, and a numeric-literal value (e.g.ka=1.0) is now honored as a constant rather than dropped to 0.0 (#261). - A name in an
[odes]RHS orinit(...)expression that is not a declared state, individual parameter, ODE-block intermediate, or reserved time variable (TIME/TAFD/TAD) is now a parse error instead of silently resolving to0.0— the ODE counterpart of the analytical guard above, which otherwise produced a “converged” but structurally broken fit (#314). - Datasets without an
EVIDcolumn no longer silently fit a dose-free model. ferx now infers a dose from a nonzeroAMTwhenEVIDis absent (matching NONMEM), so legacy datasets that mark doses only byAMT/MDV=1administer correctly. As a safety net, the reader also warns whenAMT != 0rows are not treated as doses (W_AMT_NOT_DOSED) or when a population with observations parses zero dose events (W_NO_DOSES) (#262). - Autodiff builds now fall back to finite differences for analytical models the single-snapshot AD kernel cannot represent faithfully: non-log-normal ETAs (additive / logit), conditional (
if-branch) individual-parameter expressions, log-transform-both-sides (log_additive) error, eta-dependent[scaling] obs_scaleexpressions (e.g.obs_scale = V), and time-to-event ([event_model]) hazard likelihoods. The kernel hardcodes the log-normal mapparam = tv*exp(eta)(plus a log-wrap for LTBS, a subject-static eta-frozenobs_scale, and the PK NLL rather than the hazard term for TTE), so these previously produced inner gradients inconsistent with the objective - a small bias on well-conditioned data, but on ill-conditioned FOCEI-INTER fits a spurious variance-collapsed optimum with an OFV far below NONMEM’s. FD-only CI never exercised the AD path, so the divergence went undetected (surfaced by an external NONMEM/OpenPMX/ferx benchmark, FeRx-NLME/ferx-r#154). The default non-autodiff build was never affected (#278). - FOCEI covariance standard errors (non-IOV) now include the
log|H̃|EBE-response curvature for mu-referenced structural parameters, bringing the non-IOV stencil in line with the IOV stencil and matching NONMEM$COV MATRIX=Rmore closely on models with η-dependent (proportional/combined) residual error. The fixed-η̂ analytic gradient previously dropped this term — the envelope theorem zeros the inner objective but notlog|H̃|— and the resulting SE gap grew with the proportional error magnitude. Additive-error SEs are unchanged (the correction is identically zero when∂R/∂f = 0) (#274). - IOV models:
[derived]columns,[output]individual parameters, and the TAD column insdtabnow use each observation’s occasion kappa instead of silently treating every kappa as zero. Post-fit diagnostic columns that depend on a κ-varying parameter (e.g.CL,V,KA) were wrong for IOV subjects; the fitted estimates, OFV, and IPRED/IWRES were unaffected (#238). - The
sdtabTAD column now shifts each dose by its own absorption lag — evaluated with that dose’s occasion kappa and that dose’s covariate snapshot — rather than applying the observation’s lag to every dose. This changes TAD only when the absorption lag varies across doses, i.e. when it carries IOV (kappa) or depends on a time-varying covariate, and dosing spans the differing values (e.g. BID across two occasions); models with a constant lag are unaffected (follow-up to #238). - FOCE (non-interaction) omega standard errors now match NONMEM
$EST METHOD=1$COVARIANCE MATRIX=R(to ~3–6% on warfarin, previously ~31% low). The covariance step had added the Ω prior (η̂ᵀΩ⁻¹η̂ + log|Ω|) on top of the Sheiner–Beal marginal, which already carries Ω throughR̃ = HΩHᵀ + R— double-counting Ω and flattening the omega-block curvature. FOCE estimates were already correct; only the SEs were affected (#243). - The covariance step now succeeds on models with a mixed block + diagonal Ω: the structural-zero cross-block off-diagonals (
free_mask == false) are excluded from the parameter set like FIX parameters, so their flat Hessian diagonal no longer aborts the step. This affected both FOCE and FOCEI (#243). - Covariance standard errors now match NONMEM
$COVARIANCE MATRIX=R(within ~2% on warfarin). The covariance step reconverges the inner EBE loop at every finite-difference point — holding the EBEs fixed gave an indefinite Hessian that was clipped and inflated theta/sigma SEs 30–94× — and applies the correct factor of two for the−2·logLobjective (every SE was previously1/√2too small) (#209, #196, #129). - Covariance step:
fd_hessian_stepis now an initial step; ferx automatically halves it up to 8× if any diagonal FD stencil is non-finite (#223). - IOV FOCEI marginal likelihood now matches NONMEM after the Almquist Laplace correction (#109, #203).
- SAEM no longer collapses a block Ω to a rank-1 (near-unit-correlation) solution (#191).
- Stacked
EVID=4reset occasions are segmented onto a monotonic timeline (#195, #197). sdtabno longer emits stray ETA columns (regression from #185).warfarin --simulateworks again, and the docsverify-buildstep is fixed (#199, #200).- FREM with
log_additiveerror model: covariate pseudo-observation predictions are no longer log-transformed. The FREM override (θ + η) now runs after the LTBS log-transform, producing raw covariate predictions as NONMEM does. Without this fix the OFV was inflated by ~10 orders of magnitude. - FREM with IMPMAP/IMP: the IS posterior Hessian now applies the FREM R-diagonal override (EPSCOV² variance) for covariate pseudo-observations, matching the FOCEI and SAEM code paths.
frem_predictionsandfrem_sigmafit options are now registered as framework keys, suppressing spurious “not used by method” warnings on non-FOCEI chains.- FREM data generation: missing covariate values (default -99) are now excluded from mean/variance computation and their pseudo-observation rows are omitted, matching PsN/NONMEM behavior.
- FREM data generation: records within each subject are now sorted by (time, event priority) to prevent backwards-in-time sequences that NONMEM rejects.
Performance
- The inner EBE optimizer now selects between dense BFGS and L-BFGS by the inner problem dimension: dense BFGS (full inverse-Hessian, Newton-fast and cheap at low dimension) for the usual
n_eta ≲ 8PK case, and L-BFGS (two-loop recursion,O(m·n)per step) once the inner dimension is large enough that the denseO(n²)update dominates — high-dimensional IOV (n_eta + K·n_kappa). Converges to the same EBEs (estimates and OFV unchanged); the crossover keeps small problems on the faster dense solver while making large random-effect inner problems scale (benchmarked: L-BFGS ~2× faster at dim 64, ~17× at 256) (#367). - The covariance step is now built as a single parallel work-list over the finite-difference points (subjects iterated serially within each point) instead of firing a per-subject parallel reduction at every perturbed point. This removes the fork/join overhead of up to
4·n_freerayon barriers in series — the bottleneck was scheduling, not core utilisation — making the covariance step ~9–11× faster across error models and structures, with bit-identical results. Both stencils are flattened: the non-IOV analytic-gradient difference and the IOVOFV-second-difference (the latter has~2·n_free²points, so it benefits even more) (#256). - The covariance Hessian is built from a central difference of the analytical population gradient — reusing H-matrix columns for mu-referenced parameters instead of finite-differencing predictions — making the covariance step ~9× faster than scalar finite differencing on warfarin, scaling with the number of free parameters (#209, #196).
- Autodiff inner gradients now flow through
EVID=3/4resets and lag time, removing a large finite-difference fallback slowdown (#198).
Fixed
simulate()now reproduces a fitted or fixedblock_sigmacross-endpoint correlation (#672): paired rows sharing a subject time and occasion (e.g. total/unbound assays, per-CMT or covariate-selected) are drawn from the dense residual covarianceRinstead of independent per-row normals, so a VPC or posterior-predictive check now recovers the specified residual covariance instead of understating it.
0.1.5 - 2026-06-01
Released before this changelog was started. See the GitHub release and git log v0.1.0..v0.1.5 for details.
0.1.0 - 2026-05-29
Initial tagged release. See the GitHub release.
ferx 0.3.0.9000 (development version)
Breaking changes
A
block_omegadeclared beside a separate diagonalomeganow fits the model it declares, so estimates, OFV, AIC and BIC of such a fit change (ferx-core #1018, ferx-core #1364). Every estimator that runs the outer optimizer -foce,focei,laplace,gn,gn_hybrid, under anyoptimizer- searched the cross-block Cholesky entries anyway, soblock_omega (ETA_CL, ETA_V)besideomega ETA_KAestimatedCov(ETA_KA, ETA_CL)andCov(ETA_KA, ETA_V)and returned the full 3x3 block fit, whilen_parameterscounted only the declared block. Those covariances are now held at exactly 0, report a standard error of exactly 0, and are not perturbed byferx_sir()or by asymptotic uncertainty draws - a SIR run on such a model also loses those dimensions from its Student-t proposal, so its weights and effective sample size move.n_parametersis unchanged and now matches what was estimated. The same applies to ablock_kappabeside a separatekappa.The bundled
warfarin_block_omegaexample is exactly this shape, and the move was measured at both pins:OFV AIC BIC n_parameters Cov(KA,CL) Cov(KA,V) old pin 8694824-283.316670-267.316670-245.7128278 -0.0316590.020686new pin 8372248c-280.485777-264.485777-242.8819348 00Declaring the full block instead -
block_omega (ETA_CL, ETA_V, ETA_KA)- fits at OFV-283.316670with 10 parameters and the same two covariances to every digit, which is what the old mixed declaration was returning: the same answer as a full block, reported as an 8-parameter model, so its AIC and BIC were computed from a parameter count two short of what had been estimated. The remaining four omega entries move by less than 1e-3, and these_omegaentries for the two held covariances are now exactly0.ferx_iivsearch()is affected for the same reason: a block-stage candidate with a block beside another eta is now ranked on the model it declares, and the caveat note that said otherwise is gone.A model file that calls a function the engine does not have now fails to parse (ferx-core #1332).
CL = TVCL * tanh(ETA_CL)used to parse, validate, fit and converge while computingTVCL * ETA_CL, because an unknown name evaluated as the identity. This covers every block that parses expressions -[individual_parameters],[odes],[scaling],[derived],[error_model]magnitudes - and conditions. Names stay case-insensitive, soEXP(...)/LOG(...)are unaffected. A file that relied on the no-op was already computing something other than what it reads as, so the new errors are all true positives.A
pk(...)call that binds both spellings of one PK slot to different values is now a parse error (ferx-core #1048).v=/v1=(central volume),q=/q2=(inter-compartmental clearance) andlagtime=/alag=(absorption lag) each name one slot, sopk one_cpt_iv(cl = CL, v = VA, v1 = VB)used to parse, fit and return predictions off by the ratio of the two volumes with no warning at all. Both spellings bound to the same value stays legal.No bundled example is affected by either new parse error: all 67 models in
inst/examples/models/were parsed at the new pin and none fails.A
thetawhose initial estimate lies strictly outside its own declared range is now refused before any fitting (ferx-core #1251, #1309). The pinnedferx-core/ferx-toolsrevision moves to944cbf1eto pick this up.theta TVCL(0.05, 0.1, 10.0)used to fit quietly from0.1— a factor of two away from the number in the file — on every run; it now stops withE_THETA_INIT_OUTSIDE_BOUNDSbefore the first objective evaluation. NM-TRAN refuses the same stream outright (error 24). The comparison is against the declared numbers, sotheta TVCL(-5.0, 0.0, 10.0)is caught even though the start and the declared lower bound both pack onto the engine’s internal1e-10floor. A start sitting exactly on a bound is left alone, andmaxiter = 0runs are exempt, as forE_OMEGA_INIT_AT_RAIL.A start outside one of the engine’s internal rails instead — the implicit theta cap, the
omegaguards, thesigmaguard — stays a warning (W_INIT_OUTSIDE_BOUNDS), and now carries remediation guidance inferx_warnings()(see New features).No bundled example is affected:
ferx checkwas run over all 66 models ininst/examples/models/at the new pin and none carries a start outside its declared range.An unrecognised
[section]in a.ferxfile is now an error (ferx-core #1040). Sections were read by name lookup, so one the engine did not know was never read and never reported: a misspelled[fit_option]left the model validating clean while the fit ran with the default method and no covariance step, returning without standard errors. Section names are now closed-world — an unknown header fails the parse withE_UNKNOWN_BLOCK, naming the offender, its line, the valid set and a did-you-mean.ferx_model_validate()follows suit on two counts. An unknown section now counts against the returned$ok; it was printed as[unknown section]and then left out of the status, sores$okwasTRUEfor a model carrying a section the engine would ignore. And the list of valid sections comes from the engine (ferx_rust_known_blocks()) instead of a copy maintained in R — the copy had drifted, soferx_model_validate(ferx_example("two_cpt_oral_cov")$model)reportedcovariates [unknown section]for a perfectly valid model, and it still advertised[initial_values], which the engine dropped years ago (ferx-core e5e934d).If your model file carries
[initial_values], delete it. It has been dead weight since initial estimates moved inline into[parameters], and it now fails the parse withE_DEPRECATED_BLOCK, naming the replacement. The three files underexamples/models/that still had one have been fixed.A dose attribute your model also reads is now an error (ferx-core #993).
A dose attribute your model also reads is now an error (ferx-core #993, ferx-core #1004).
F,LAGTIME/ALAGand the compartment-indexedF{n}/ALAG{n}/LAGTIME{n}are applied by the engine at the dose event. A model that declared one and also referenced it in[odes](the right-hand side or aninit(...)seed), the[scaling]readout, or the[adaptive_dosing] observesignal was applying it twice — silently, and by exactly that factor. Such a model now fails to parse withE_DOSE_ATTR_DOUBLE_USE, naming both readings and the fix.D{n}/R{n}carry the same reservation but are consulted only for a codedRATE=-2/-1dose, so that collision is reported against the dataset instead and a model whose data never codesRATEis untouched. Reads from[derived]/[output]are post-solve reporting and stay silent.Analytical (
pk ...) models are covered too (#1004). The first pass rejected ODE models only, on the argument that an explicitpk(..., f=F)mapping made a second use “stated rather than silent”. It did not: nothing in the model file says the value is applied twice, and a[scaling]or[adaptive_dosing] observeexpression that reads a mappedf=/lagtime=/alag=parameter applied it once at the dose and once where it was read — on the default engine, with no diagnostic, measured at exactlyFon the prediction. Both engines now reject it with the sameE_DOSE_ATTR_DOUBLE_USEcode.The remedy differs by engine, and the message says which applies. On an ODE model the name routes the parameter, so renaming it fixes the model. On an analytical model the name is inert and the mapping binds it, so the fix is to drop the
f=/lagtime=/alag=argument from thepk(...)call — renaming changes nothing, because the mapping follows the parameter. The message quotes the mapping as you wrote it,alag=spelling included.If you map a dose attribute and also read it — e.g.
pk one_cpt_oral(cl=CL, v=V, ka=KA, f=F)together with[scaling] obs_scale = V / F— that model now fails to parse. Drop whichever half was not meant.If you have an ODE model that folds
Finto the absorption flux — the pre-dose-entry convention, e.g.d/dt(central) = F * KA * depot / V - ...— it will now fail to parse instead of quietly computingF². Drop theFfrom the right-hand side; if the parameter was never bioavailability, rename it (the name is what routes it).Two shapes stay accepted. A parameter merely named
Fthat nopk(...)argument maps is an ordinary parameter, so the usualCL/F,V/Fapparent-parameter convention is unaffected. And an analytical[initial_conditions]read is fine: an initial condition is not an absorbed dose, so the engine seeds the amount withF = 1and no lag, andinit(depot) = F * 500— the bioavailable residue of a pre-study dose — appliesFexactly once. Note the scope of that second one: the same reasoning has not yet been carried over to the ODE engine, where aninit(...)seed reading a dose attribute is still rejected (ferx-core #1046).One analytical shape still escapes the check. A parameter assigned only inside an
ifwith noelseis bound bypk(..., f=F)but resolves to nothing when[scaling]reads it, givingNaNpredictions on a clean parse — tracked as ferx-core #1026. Do not read a mapped dose attribute back in[scaling]on the assumption that the parser will stop you.Both readings make ferx stricter than NONMEM, measured on NONMEM 7.6.0:
$PKdefiningF1andS2 = V/F1runs clean underADVAN2and returns predictions scaled by exactlyF1, and a$PKF1referenced in$DESunderADVAN13quietly computesF²— neither draws a diagnostic (ferx-core’snonmem_anchor/analytical_dose_attr_double_use_*andnonmem_anchor/dose_attr_double_use_*). A control stream translated literally can therefore fail to parse in ferx even though it ran in NONMEM.An unrecognised
[block]name is now an error (ferx-core #1040). Blocks were read by name lookup, so a header the parser did not know was never read and never reported: a misspelled[fit_option]left the model validating clean while the fit ran with the default method, the default iteration cap and no covariance step — returning without standard errors and no indication why. The same went for[scalings],[outputs]and friends. Block names are now closed-world, like the keys inside a block already were: an unknown header isE_UNKNOWN_BLOCK, listing every offender with its line, the full valid set, and a did-you-mean for a near match. Three neighbouring silent drops go with it — an instance name on a block that takes none ([fit_options DOSE]) or missing where one is required ([covariate_nn]) isE_BLOCK_INSTANCE_NAME; a block whose cargo feature this build lacks ([event_model],[markov_model]) isE_BLOCK_FEATURE_DISABLED; and[initial_values]— ferx’s own former spelling for initial estimates, unread since they moved inline into[parameters]— isE_DEPRECATED_BLOCK. If a model of yours still carries an[initial_values]block, delete it: it has done nothing for several releases and now stops the parse. No bundled model underinst/carries one. One rough edge to know about:ferx_model_validate()still prints its own “Sections present” table from a list that predates this change, so on an unusual block that table can disagree with the verdict below it — the engine diagnostics are the authoritative half.obs_scale = TIMEis now a parse error, and an undefined name in[scaling]no longer reads as zero (ferx-core #1028).obs_scaleis subject-static — evaluated once att = 0— soobs_scale = TIME(or= T) could only ever have read0; the error names the Form Cy = <expr>readout as the place for a time-dependent term. Separately,[scaling]expressions never registered their covariate references as required data columns, andferx_predict()ran no covariate check at all, so an unresolvable identifier reached the predictor as the covariate map’s0.0default. Both halves now register their references, andferx_predict()reportsE_MISSING_COVARIATEfor a missing column just asferx_fit()andferx_simulate()already did — so aferx_predict()call that silently used0for a missing column now errors. One more narrow break: a[scaling]expression referencing an undeclared data column namedTnow reads the model-time built-in instead, matching[odes]where that name has always been reserved. DeclareTin[covariates]to keep it a data column; whenever the fold does happen ferx warns and names both escapes, so it is never silent.TAFD/TADare unaffected and stay ordinary covariate references.method = "imp","impmap"and"bayes"are rejected on a model with no random effects, and pure"gn"warns (ferx-core #1006, ferx-core #1007). All three already refusedn_eta = 0at run time, so amethod = c("focei", "imp")chain ran its whole FOCEI stage before failing.E_METHOD_NO_RANDOM_EFFECTSnow fires up front, anywhere in a chain. One consequence when upgrading: a chain withimp_eval_only = TRUEon a fixed-effects-only model previously returned a fit result with the IMP failure downgraded to a warning, and now returns this error instead. Pure Gauss-Newton is start-sensitive atn_eta = 0— with no inner EBE loop to absorb a poorsigmastart the BHHH step can collapse far from the optimum, with onlyConverged: NOas the signal — so it warns (W_GN_NO_RANDOM_EFFECTS) and points at"gn_hybrid"/"focei". Both are suppressed when a later stage re-optimises the GN result. The bundled fixed-effects-only examples (one_cpt_iv_pooled,binary_logistic) requestfoceiand are unaffected.
New features
ferx_globalsearch()- global model search from R (#364, option 3 of #347). The sixth and last tool of the search family, over ferx-core’srun_globalsearch. Whereferx_modelsearch()decides the structure with the covariate model fixed andferx_covsearch()the covariates with the structure fixed, this lays both out as one grid - every structural category an axis with its values as alleles, every optionalCOVARIATE?pair an axis withnoneand each of its forms - and decides them together, either exhaustively or with pyDarwin’s genetic algorithm (algorithm = "ga", the default). It takes the two entry forms every other tool has (config = "x.ferxsearch"or inlinemodel/data/search_space), the samedirectory/resume/threads/progressmeanings, and aprint()/summary()pair in the same idiom.[rank] typedefaults to"penalized"here, where every other tool defaults to a BIC, and the search charges three things the criterion cannot see under any criterion: a gene that changed nothing in the rendered model, a candidate that produced no fit, and a fit the strictness gate refused. Those arecharge_non_influential,charge_gateandcharge_crashon$models, beside thecriterionand thefitnessthey sum to, so a genome that lost to a tie-break penalty does not read as though it lost on OFV;$penaltiesis the effective schedule the run charged.$axesand$space_sizeare the grid,$generationsthe genetic algorithm’s trajectory, and$modelsthe engine’s ownmodels.csv- whichferx_search_results(dir, type = "models")now recognises as a fourth model-table schema, so a run produced byferx globalsearchon the command line reads back the same way.Two knobs take a named list validated against the engine’s own key list, so an unknown setting is refused by name before a config file is rendered:
gafor[globalsearch.ga]andpenaltiesfor[rank.penalties].algorithmis matched exactly rather than by prefix.[globalsearch]is therefore no longer reported as a section no R tool runs. Note the name collision this shares with nothing else in the package:ferx_fit(settings = list(global_search = TRUE))is a global optimizer phase inside the estimation of one model;ferx_globalsearch()is a global search over models. The name here is the engine’s, the.ferxsearchfile’s, the CLI’s and Pharmpy/pyDarwin’s, so a search stays portable between them. New bundled exampletwo_cpt_oral_global(a one-compartment, covariate-free model on thetwo_cpt_oral_covdataset, with its own.ferxsearch) andinst/examples/ex_globalsearch.R.The engine gained per-parameter priors for penalized ML / MAP estimation (ferx-core #254), declared inline in the model file as
prior(value, rse = 25%)on anytheta,omega,sigmaorkappa. Reachable from R today by writing the declaration into the.ferxfile: the fit runs, the penalty reaches the standard errors and the SIR intervals, and AIC/BIC stay on the data half of the objective.The three fields the engine added to report it -
ofv_data,ofv_priorand the per-parameterprior_summary- are not surfaced on theferx_fitobject yet.fit$ofvis the penalized objective, and the split is not readable from R:theta TVCL(0.134, 0.001, 10.0) prior(0.15, rse = 10%)on the bundledwarfarinmodel fits tofit$ofv = -279.1978while the console trace reports the data half,-280.126253. Surfacing the three fields is a follow-up.[covariate_nn]models with IOV now get the exact analytic FOCE/FOCEI outer gradient (ferx-core #1339), sosettings = list(optimizer = "auto")on such a model resolves to L-BFGS instead of derivative-free BOBYQA over every network weight, andgradientreports analytic. This also covers IOV models carrying an[initial_conditions]baseline, anobs_scaleexpression or an analytic Form C readout, and drops the old 24-wide cap on anobs_scaleexpression’s(theta, eta)width.A
.ferxsearchsection no R tool can run now says so, instead of loading clean and being ignored (#347, part of the #334 search epic). The engine’sTOOL_SECTIONSadmits[globalsearch]and[structsearch]; this package binds neither. So a file written from the ferx-core docs loaded without complaint, and whichever R tool it was handed to ignored the section addressed to a different tool - a[globalsearch]space run throughferx_covsearch()was a stepwise search, silently, with a different answer than the one asked for.ferx_search_config()and every tool that takesconfig =now warn, naming the section and the caller, andprint()of a config marks the section underTool sections:as one no R tool runs. The warning is classedferx_search_unconsumed_section, so a script that knows it is running the stepwise half can muffle that one condition without suppressing the rest. What the warning reports is the complement of the sections this package has a tool for - not a list of the two known names - so a section a later ferx-core adds toTOOL_SECTIONSis reported from the day a file can carry it, and stops being reported on the day a binding for it lands here. Where a section can be run somewhere else the warning says where, by name:[globalsearch]pointed atferx globalsearch, while[structsearch], which is accepted vocabulary with no engine module and no CLI command behind it, is reported without a remediation rather than with one that names a command that does not exist. (ferx_globalsearch(), above, has since taken[globalsearch]off the reported list entirely;[structsearch]is the one section left.)ferx_search_config()reports the[rank.penalties]schedule it validated (#348, folded into #332).[rank] type = "penalized"ranks on pyDarwin’s penalized fitness and[rank.penalties]overlays the individual charges; the loader validated the table - a negative charge and an unknown key are both errors - and then dropped it, socfg$rankheldtypeandcutoffonly and a config withtheta = 5.0printed byte-identically to one with the defaults.cfg$rank$penaltiesis now a named numeric of the effective schedule (the file’s keys over the engine’s defaults, which is what the run would charge) andcfg$rank$penalties_setnames the charges the file changed.print()shows the schedule, starred like the strictness block, whenever the file ranks onpenalizedor changes a charge, and stays quiet otherwise. Results were never affected: the tools pass the config path to the engine, which reads the table itself.ferx_coef(fit, "TVCL")andferx_se(fit, "TVCL")pull a parameter by name, andfit$estimatesnow carries row names (#299). The tidy estimates table identified its rows only through aparamcolumn, so the first natural attempt at reading a coefficient -fit$estimates["TVCL", "estimate"]- returnedNArather than erroring, because that is what[does to a data frame with default row names. Row names are now set fromparam. A name the engine allows in two blocks - aCLdeclared as both a theta and an eta - is qualified by its block on both rows (CL.theta,CL.omega), so the bare name never addresses one of a colliding pair by position and both stay reachable; non-colliding names, which is the whole table in practice, are untouched, andparamstill holds the name as declared. The two accessors are the loud version of the same lookup: an unrecognised name is an error naming the closest available parameters, and an ambiguous bare name is an error naming the qualified keys, so a mistyped or colliding coefficient can never be read as an unestimated one.ferx_se()additionally warns when the fit carries no standard errors at all (no covariance step, or a failed one) instead of handing back a silentNA; note that a parameter declaredFIXis not that case - the engine gives it an exact0, which both the table andferx_se()report as such.ferx_warnings()now explains a clamped initial estimate (ferx-core #1251).W_INIT_OUTSIDE_BOUNDSarrives under the newinit_outside_boundscategory, which this package did not know: the warning printed with no remediation guidance at all. It now says that the fit did not start from the value in the model file, and what to change. (The scale is left to the engine’s own message, which since #1251 readsan SD of 1.000e3for asigma.) The category is deliberately distinct fromboundary_estimate, which is about where a fit ended and which drivesbootstrap’sskip_estimate_near_boundary,reject_on_boundaryand this package’s own strictness gate; a clamped start wearing that category would silently drop bootstrap replicates.Two new
[covariate_model]forms, both reachable from.ferxfiles and from.ferxsearchspaces viaferx_covsearch()/ferx_modelsearch().Additive (
+) covariate effects (ferx-core #1313). A trailing operator token makes a relation a term added to the parameter instead of a factor on it —CL ~ WT linear(center = 70) +becomesCL = TVCL * exp(ETA_CL) + THETA_CL_WT*(WT - 70).*stays the default. Mu-referencing switches off for a parameter carrying an additive relation (the typical value is a sum) and the parser warns. This closes the last MFL operator gap, soCOVARIATE(..., +)is no longer reported byferx_search_coverage(). Note: Pharmpy reuses its multiplicative template under+, so a model translated from Pharmpy will not reproduce its equations.categorical2(ferx-core #1312), Pharmpy MFL’scat2. It contributestheta_kat each non-reference level wherecategoricalcontributes1 + theta_k— the same degrees of freedom and an exact reparameterisation, so it is a choice of how theta reads, not a cheaper test. The null moves with the form:fix = 1is “no effect” forcategorical2wherefix = 0is forcategorical.[rank] type = "penalized"now works in a.ferxsearchconfiguration (ferx-core #1185). Previously declared but unimplemented, soferx_search_config()refused it; it is now implemented for every search tool, which meansferx_covsearch()andferx_modelsearch()can rank on pyDarwin’s penalized fitness: OFV + 10 per estimated theta / omega / sigma element + 100 for non-convergence, a failed or absent covariance step, a parameter correlation above 0.95, or a condition number above 1000.[rank.penalties]overlays any individual charge.SAEM residual SD is less noisy. For eligible single additive and proportional error models SAEM now averages the residual sufficient statistic instead of taking the final draw (ferx-core #1321), which reduces Monte Carlo noise in the reported residual SD.
cov_inner_tolis now honoured as a framework-level covariance key (ferx-core #956), so it no longer reports that it is ignored for estimators whose covariance step applies it. A fit whose last estimating stage isbayesruns no covariance step and now says exactly that for all six covariance keys. A non-positive or non-finite value is rejected at parse time; such a value used to parse and then silently make every covariance-step EBE reconvergence exhaustinner_maxiter.Performance:
settings = list(n_agq = ...)withmethod = "focei". The Gauss-Newton-anchored quadrature refinement now assembles its grid-response gradient term analytically rather than rebuilding the anchor atx +/- hfor every free population parameter (ferx-core #251): 30-50% fewer provider calls on warfarin fixtures, and roughly 2x less provider time and ~30% faster wall-clock on an ODE model. This is on by default and changes the optimizer trajectory, so converged estimates, OFVs and standard errors may move within the convergence tolerance relative to the previous pin.Performance:
[covariate_nn]models with a time-varying network input are now analytic on both the FOCE and FOCEI loops (ferx-core #1300). Subjects that fell back to reconverged finite differences (~300x per objective evaluation on the vancomycin DCM) now take the exact gradient; models without a network are numerically unchanged. Requires thenncargo feature, which is off in the default package build.Closed-form steady-state bolus models with estimated lag times now use analytical event sensitivities instead of falling back to finite differences (ferx-core #1311).
databesideconfigis now an error, and an infinite numeric argument is refused inferx_covsearch(),ferx_allometry()andferx_modelsearch()(#335 review). Both were silent drops.datawas left out of the config-vs-inline mutual exclusion, soferx_covsearch(config = "x.ferxsearch", data = "other.csv")ran the search on the dataset the file names and said nothing - a result for a different dataset than the one asked for. AndInfpassed R’s validation while the bindings emit a key only when its value is finite, socutoff = Inforp_forward = Infdisappeared on the way to the configuration and the search ran as though the argument had never been given. Both now stop with a message naming the argument.Automatic model development:
ferx_amd()andferx_amd_plan()(#338, the last phase of the #334 search epic; ferx-core #1184). Pharmpy’samdfrom R: the whole pipeline over one model and one search space - structural (modelsearch), variability (iivsearch), residual error (ruvsearch), inter-occasion variability (iovsearch), allometric scaling (allometry) and covariates (covsearch) - each step starting from the model the previous one selected, seeded with its estimates.strategyreorders them ("default","reevaluation","SIR","SRI","RSI") andskipleaves one out; both entry forms of every other search tool work here too, a.ferxsearchfile or the inline arguments rendered into the same configuration.One space describes every step. The engine partitions it by statement kind and hands each tool only the statements it can read, so
ABSORPTION(FO); PERIPHERALS(0..1); IIV?(@PK, exp)is a structural search and a variability search without either tool being handed the other’s statements - both of which would refuse them. A[rank]criterion is narrowed the same way: the two steps that select on a likelihood-ratio test keep their own p-values rather than being handed a BIC they would reject.A winner-only view is not what this returns. The strictness verdict (
passed, with the gate’s ownfailures) and the termination status (converged) are columns at both levels - on every candidate of every step in$candidates, and on the model each step selected in$steps- beside the criterion, the dOFV and the wall clock. A step that was skipped carries the reason in as many words, and a step that ran and failed is a row saying so rather than a missing one: the pipeline carries on from the model that step was handed.print()shows the step table,summary()adds every step’s candidates and everything the gate excluded, and$summary_textis the engine’s own report asferx amdprints it.ferx_amd_plan()answers the same question before any fitting: which steps would run, in which order, and why one would not. It is the plan the engine computes before its first fit rather than a second derivation of it, so a space can be checked for a few seconds instead of an afternoon.A run writes
steps.csv,candidates.csv,final.ferxand one directory per step (each holding that tool’s own fuller record) intodirectory;directory = NULLruns the pipeline in a temporary directory that is removed on the way out, which keeps the tables on the object but leaves nothing to resume from.inst/examples/ex_amd.Rruns it end to end, and the newwarfarin_amdexample ships the starting model (the plainest thing the warfarin data supports, so the pipeline has something to decide) with a.ferxsearchcarrying a structural and a variability space plus an[amd]section.An empty inline
search_spaceis now refused by every search tool that requires one (ferx_amd(),ferx_covsearch(),ferx_modelsearch(),ferx_iivsearch()).search_space = "",character(0)and a vector of blank lines used to pass R’s validation and reach the engine as no[space]section at all - a covariate search with nothing to search, and for AMD a pipeline that plans every step but the residual one as skipped, which isferx_ruvsearch()wearing six rows. The rule was about the argument being absent; it is now about the search having a space.Variability-structure search:
ferx_iivsearch()andferx_iovsearch()(#337, part of the #334 search epic; ferx-core #1183). Pharmpy’siivsearchandiovsearchfrom R.ferx_iivsearch()decides which parameters carry an eta and which of those etas are correlated;ferx_iovsearch()decides which parameters earn a kappa and whether the etas beside them are still worth keeping. Both take a.ferxsearchfile or the inline arguments, rendered into the same configuration by the same loader.The two stages stay two stages. An iivsearch is two searches in sequence
- the number of etas (
step_kind = "no_of_etas"), then the block structure over the winner of that ("block_structure"), then a comparison with the input ("compare_to_input") - and an iovsearch is a kappa-removal step followed by an eta-removal step. Every row of$modelscarries the stage it was fitted in, and$stepsis the engine’s own per-stage ranking: the parent each candidate was compared with, its improvement over that parent, and where it placed. Collapsing them into one table would report a search that was never run.startsis on every row too, because a candidate whose largest block has more than two etas is fitted from more starting points than a diagonal one (block_retriesper eta beyond two).
Every structure row is labelled with the model’s own declared names. The engine’s
descriptioncolumn is Pharmpy’s spelling, in parameter names ([CL,V]+[KA]), and is whatmodels.csvcarries; beside itstructure,eta_labels,block_labels(andkappa_labels/kappa_block_labelsfor iovsearch) say the same thing in the model’s declared random-effect names ([ETA_CL,ETA_V]+[ETA_KA]), per the output label convention. The mapping is read off each candidate’s own text with the engine’sVariabilityText, so a model that calls its eta something other thanETA_<P>is labelled as it is written rather than as R would have guessed; a random effect the model does not name falls back toOMEGA(i,i)/KAPPA<i>.print()names the correlated pairs with the convention’s tilde (ETA_V ~ ETA_CL), which is the whole point of this search’s output.ferx_iovsearch()reads its occasions from the base model’s[fit_options] iov_column; a base without one, or acolumnargument that disagrees with it, is refused by name before anything is fitted, as is adistribution = "explicit"withoutgroups.ferx_iivsearch()refuses a structural or covariate space,algorithm = "skip"with the block stage also skipped, andcorrelation_algorithmbesidesimultaneous_stepwise(which decides the blocks as it adds each eta and so has no second stage).ferx_search_results(type = "models")now tells the three tools that write amodels.csvapart - modelsearch’s 21 columns, iivsearch’s 18, iovsearch’s 19- off the file’s own header, and reports which in the
toolattribute, the waytype = "steps"already did for the two stepwise tools. A run produced byferx iivsearchorferx iovsearchon the command line is readable from R either way.
inst/examples/ex_iivsearch.Randex_iovsearch.Rrun them end to end.warfarin_block_omeganow ships a variability.ferxsearchas$search(its base already blocks ETA_CL with ETA_V and keeps ETA_KA diagonal, so the search tests those decisions rather than confirming them), andwarfarin_iovan inter-occasion one.- the number of etas (
Residual-error model search:
ferx_ruvsearch()(#336, part of the #334 search epic; ferx-core #1182). Pharmpy’sruvsearchfrom R: each iteration adds one residual-error feature to the model the last one kept, fits every candidate, and accepts the largest improvement the likelihood-ratio test calls significant atp_value. The four families areIIV_on_RUV(a per-subject scale on the residual SD, tested only when the estimation method has eta-epsilon interaction),power,combinedandtime_varying(groups - 1candidates, cut at the time-after-dose quantiles);skipleaves a family out, andcwres_prescreentakes Pharmpy’s cheap path of screening on the parent’s CWRES and refitting only the winner.There is no search space to state, and a file that states one - or a
[rank]asking for a BIC or a dOFVcutoff- is refused by name when it is read: this search selects on the likelihood-ratio test, not on a ranking criterion. The search always starts from a plain proportional error model, fitting one first when the input is not one, and the final comparison checks the accepted stack against the input as well, so a search can return the model it started from.The object is the engine’s step table - one row per model fitted, with the p-value,
convergedand the strictness verdict beside the dOFV, plus theRuvFeaturelabel and itsFamily, so “which residual form won, and at what p-value” is a table rather than prose.print()shows the iteration table and the selected error model;summary()adds every form that was not selected with its reason. Every fitted model’s text comes back named by candidate id, and the winner comes back as a fittedferx_fit.$candidatesis scoped to the steps the run actually took, in every search tool. The engine rewrites the steps it executes but removes nothing, so re-using a run directory for a shorter search - two iterations, then one - used to fold the earlier run’s leftover candidate tables into the new result, which then contradicted its own step table.ferx_covsearch()andferx_modelsearch()carried the same defect and are fixed with it.ferx_search_results()gainedtype = "steps", which reads a stepwise run’ssteps.csvback with the engine’s own column list. Bothferx_covsearch()andferx_ruvsearch()write a file of that name with different columns, so the schema is read off the file’s own header and reported as thetoolattribute - a run produced byferx covsearchorferx ruvsearchon the command line is readable from R either way.inst/examples/ex_ruvsearch.Rruns it end to end, and theone_cpt_transitexample now ships a residual-error.ferxsearchas$search. That model iska-free while its anchor dataset was simulated with a transit chain and a first-orderkastep, so the absorption-phase residuals carry the misspecification: atp = 0.05a time-varying magnitude below TAD 1.375 is accepted for 6.2 OFV, while at Pharmpy’s defaultp = 0.001nothing is and the search correctly hands back the model it was given.Structural model search:
ferx_modelsearch()(#335, part of the #334 search epic; ferx-core #1181). Pharmpy’smodelsearchfrom R: a space of structural features -ABSORPTION,ELIMINATION,PERIPHERALS,TRANSITS,LAGTIME- searched byreduced_stepwise(the default),exhaustive_stepwiseorexhaustive, with candidates ranked on the mixed BIC unlessranksays otherwise.iiv_strategysays how a candidate’s new parameters get a random effect (absorption_delay,add_diagonal,no_add); Pharmpy’sfullblockis refused by name, since a block over the new and existing eta is a variability search’s move.The object is the engine’s model table - one row per model built, with
converged, the strictness verdict and its reason beside the criterion, so a model the gate excluded is a row saying why rather than an absence. Every candidate’s model text comes back named by id ($model_text), so the model ranked second can be read or refitted without re-running the search, and the winner comes back as a fittedferx_fit. Both entry forms -config =a.ferxsearchfile, or inline arguments - render the same configuration and go through the engine’s own loader, exactly asferx_covsearch()does.A space that is not a structural one, or a feature the engine cannot build (
ABSORPTION(SEQ-ZO-FO)), is an error naming the offender before the first fit.ferx_search_results()gainedtype = "models", which reads a structural run’smodels.csvback with the engine’s own column list - so a run produced byferx modelsearchon the command line is readable from R.inst/examples/ex_modelsearch.Rruns it end to end, and thewarfarinexample now ships a structural.ferxsearchas$search. On that example the search is a real decision and lands on the base model: adding a lag time buys 2.3 OFV for two parameters and a second compartment 2.1, so the mixed BIC prefers one compartment with first-order absorption and no delay. Atrank = "ofv"- no penalty for parameters - the lag model wins instead, and the two-compartment candidates are excluded by the strictness gate for being ill-conditioned (|r| = 1.0betweenTVKAandTVV2) rather than being quietly ranked first.Covariate search and allometric scaling:
ferx_covsearch()andferx_allometry()(#332 Parts 2 and 3; ferx-core #1180). The search surface shipped its validation half first; these are the first two tools to run on it, and the first model-space search reachable from R at all.ferx_covsearch()is stepwise covariate modelling - PsNscm, Pharmpycovsearch- withscm-forwardandscm-forward-then-backward,p_forward/p_backward,max_stepsand adaptive scope reduction. It returns the engine’s own step table: one row per candidate of every step, withconvergedand the strictness verdict beside the dOFV. That pairing is the point rather than a detail - a candidate that stalled at its initial estimates carries an OFV that says nothing about the model, so a step table showing only dOFV hides exactly the failure that turns a search into a selection error. A candidate the gate excluded is a row carrying its reason, never an absence. The winning model comes back as a fittedferx_fit, its relation set as a table saying where each relation came from (the base model, a forcedCOVARIATE(...), or the step that added it), and the runner’s full candidate table as$candidates.ferx_allometry()is the convention rather than new machinery:(WT/70)^0.75on every clearance the template line binds and(WT/70)^1.0on every volume, as[covariate_model]relations.fit = FALSEmakes it a model transform - you get the scaled model back and nothing is fitted, so allometry can be one step of a hand-built workflow;fit = TRUEfits the base and scaled models side by side and reports both, which is what makes the scaling’s cost visible.estimate = TRUEestimates the exponents instead of fixing them.Both take either a
.ferxsearchfile (config =, the reproducible artifact) or inline arguments. The inline form is rendered into the same configuration and handed to the engine’s own loader, so the two cannot disagree, andsearch_spaceis MFL text quoted verbatim - which is what keeps a space portable to and from Pharmpy.directory,resume,threadsandprogressmean what they mean inferx_bootstrap(), journal the same way, and make a long search resumable; Ctrl-C stops one, in-flight fits included.inst/examples/ex_covsearch.Randinst/examples/ex_allometry.Rrun both end to end.On the bundled
two_cpt_oral_baseexample the candidates converge and pass the strictness gate, and selection is decided on the p-value. The WT-only spaceex_covsearch.Rruns inline selects nothing atp_forward = 0.01- CL-WT is the best candidate at dOFV 5.4 (p = 0.020), and V1-WT is weaker still - while the wider space in the bundled.ferxsearch, which adds CRCL, does select relations and goes on to run a backward step.This is a change from what this branch first reported. Every candidate used to stall at its initial estimates, so its dOFV came out ~0.01 and the gate rejected it - a model carrying covariate thetas simply did not optimize on that dataset, whether the effect was written inline or as a
[covariate_model]relation (ferx-core #1290, fixed by anchoring the EBE warm start to the best point seen). That was the failure the verdict column exists for: without it the run would have read as “no covariate is worth adding” on data whose covariate effects are real, and the column is what made the difference visible.New bundled example:
two_cpt_oral_base- the covariate-free base oftwo_cpt_oral_cov, sharing its dataset, with its own.ferxsearchas$search. It exists because a search needs a base model that does not already carry the answer:two_cpt_oral_covmultiplies CL by(WT/70)^THETA_WTin[individual_parameters], so a covariate search on it asks whether to add an effect the model has, and allometric scaling on top of it would count body size twice. Its[fit_options]setgradient = fd, which was needed while ferx-core #1290 was open (the default analytic-gradient path stalled at the initial estimates on this model and dataset, so the search rejected its own base fit). Both paths converge on the pinned engine; the explicitfdis kept so the example’s numbers do not move with the gradient default.The search surface:
ferx_search_config(),ferx_search_space(),ferx_search_coverage()andferx_search_results()(#332 Part 1; ferx-core #1178, #1179). R had the judging half of model-space search (ferx_bic(),check_strictness()) and none of the searching half - the.ferxsearchloader, the MFL parser, the coverage check and the@-symbol resolver were all reachable only from Rust.ferx_search_config(path)loads and validates a.ferxsearchfile with the engine’s own loader, so an unknown section, an unparseable[space] mfl, an empty space, a feature the engine cannot express or an unimplemented[rank] typeis an R error naming the offender - before the first fit, rather than a run debugged by watching fits fail. It returns the resolvedbase/datapaths, the space as a feature table, and the rank, strictness and run settings, with the strictness gate reported as the file’s keys overlaid on the engine’s defaults.ferx_search_space(mfl, model, data)parses an MFL space and resolves its@-symbols and wildcards against a model and its dataset, soCOVARIATE?(@IIV, @CONTINUOUS, [pow, lin])can be seen as the explicit parameter x covariate x effect set it stands for on your model before committing to a run. MFL is Pharmpy’s grammar, quoted verbatim, which is what makes a space portable in both directions; the R side never translates arguments into MFL itself.ferx_search_coverage(space)reports the same coverage information non-fatally, as a data frame - an unsupported feature such asELIMINATION(MM)is a row with the engine’s reason, not an aborted run.ferx_search_results(directory)reads a run’scandidates.csv(or a cancelled run’scandidates.partial.csv) back with the columns typed: logicalconverged/passed/reused, numericcriterion/ofv/seconds, andNAfor the engine’s empty cells rather thanNaNor"". The column list comes from the engine, never a copy maintained in R.A
.ferxsearchsearch space ships with thetwo_cpt_oral_covexample and is reachable asferx_example("two_cpt_oral_cov")$search;inst/examples/ex_search_config.Rruns the whole surface end to end.The search tools -
ferx_covsearch()andferx_allometry()- wait on ferx-core #1180.ferx_bic()andcheck_strictness(): ranking and gating a candidate fit (#326, #327; ferx-core #1177). The two things an automated model search needs from a finished fit, and neither was reachable from R.fit$bicis the classicalOFV + p * log(n_obs). Pharmpy’smodelsearchandiivsearchrank on the Delattre et al. (2014) mixed BIC instead, which penalises random-effects parameters onlog(n_subjects)- ranking IIV structures on the observation count systematically favours the wrong model.ferx_bic(fit, type)offers all four of Pharmpy’s variants ("mixed","iiv","random","fixed"; the last reproducesfit$bic). The tally it needs,fit$bic_inputs, comes off the same packed parameter mask the engine countsn_parametersfrom, and travels in the.fitrxbundle, so a saved fit can be re-ranked without the model or the data in hand.check_strictness(fit, ...)answers whether a candidate is eligible to be ranked at all.fit$convergedis one flag and does not separate a genuine optimum from a run that never left its initial estimates, a theta pinned to a declared bound, an ill-conditioned covariance step or a near-singular correlation matrix - under automation each of those is a model-selection error. It returnslist(passed, failures, skipped)with the reasons, on pyDarwin’s default posture, and reports a gate whose input is missing as skipped rather than failing or silently passing it.Four fields feed them and are new on
ferx_fit()’s result:left_init(the outer optimizer’s own init-escape verdict),stalled_at_init,estimate_near_boundary, andmax_abs_correlation- the largest absolute parameter correlation on the natural theta / OMEGA / SIGMA scale, which for ablock_omegamodel is a Cholesky Jacobian away from the packedcov_matrixand so could not be computed in R. All four, andbic_inputs, survive aferx_save_fit()/ferx_load_fit()round-trip, andferx_covariance()refreshesmax_abs_correlationalongside the matrix it is read off.The bundle also carries what a reader needs to reach the same verdicts: the condition number (it was written from the wire field name, which
ferx_fit()had already renamed and cleared, so every bundle this package wrote stored a null and the condition-number gate came back skipped after a reload), the initial estimates the engine’s ownstalled_at_init()checks for shape before it consultsleft_init, and the packed OMEGA / KAPPA layout (fit$omega_is_diagonal/fit$kappa_is_diagonal, also new on the result) without which a reader takes correlations off the Cholesky scale for ablock_omegamodel.A fitted
block_sigmacorrelation now reaches R (ferx-core #847). A plain (non-FIX)block_sigmaestimates its off-diagonal, but the R layer never carried it: everything that rebuilds parameters from a finished fit -ferx_predict(),ferx_simulate(),ferx_simulate_with_uncertainty(),ferx_calc_npde(),ferx_sir(),ferx_covariance()- read the correlation back off the model file, so a fit whose rho had moved was silently reconstructed at its declared initial value. The fitted correlations now come off the FFI asfit$residual_correlations(a data frame ofsigma_i,sigma_j,name,rho,fixed,se), travel in the.fitrxbundle, and are passed back to the engine on every reconstruction path.The covariance matrix labels them too. The engine packs
block_sigmacorrelations last, after sigma, andferx_fit()counted every non-theta/non-sigma coordinate as omega - so a six-coordinate fit came back namedTVCL, TVV, ETA_CL, "", PROP_ERR, ADD_ERRfor coordinates that are really..., PROP_ERR, ADD_ERR, rho, shifting the sigma rows by one.ferx_covariance()had the same arithmetic and is fixed with it.check_strictness()no longer passes a fit it cannot judge, or a gate it cannot parse. A missing boundary verdict -NAon a bundle written before the predicate existed - was read as “not on a boundary” and quietly passed; it is now reported underskipped, like the other gates with no input. And the switches were read withisTRUE(), which treats anything that is not a length-1TRUEas “gate off”:reject_on_boundary = "TRUE"disabled the gate, andmax_condition_number = "typo"coerced toNAand disabled that one. Both now error. An explicitNAthreshold still disables its gate - a threshold nothing can exceed is not a gate.ferx_bootstrap()is interruptible with Ctrl-C (#315). A 200-replicate run held the console until it finished: Ctrl-C did nothing,ferx_stop()is process-level and there is noferx_bootstrap_async(), so the only way out of a run started with the wrongsamplesor the wrong model was killing the session. The engine ran on the thread that entered.Call, so nothing was free to service R interrupts - the fix is the oneferx_fit()already uses: the replicates fit on a worker thread while the R thread polls for an interrupt several times a second and flips the engine’s cancellation flag, which the base fit and every replicate see.A cancelled run raises
ferx_bootstrap: cancelled by userrather than returning what it had. The replicates the interrupt aborted come back from the engine as failed fits, so a returned object would be a normal-looking summary computed over however many happened to finish, with nothing on it to say the run was cut short. What did finish is not lost whendirectorywas set - the engine writes each replicate as it lands, soferx_bootstrap_summarize(dir)summarises them andresume = TRUEpicks the run back up - and the raised message says so.Compartment-free (
$PRED-equivalent) structural models now have a bundled example.ferx_example("emax_timecourse")fits a synthetic Emax response time course whose[structural_model]declares named equations ending iny = <expr>, with nopk,ode, compartments, doses, or integration. The model and its fixed-seed, 30-subject dataset mirror the ferx-core #811 NONMEM anchor; the corresponding estimates and standard errors agree to about 1e-4. Seeinst/examples/ex_emax_timecourse.Rfor the complete R workflow (ferx-r #312).ferx_bootstrap(resume = TRUE): continue an interrupted run (#317). A 200-replicate bootstrap that died partway - a killed session, a full disk, a laptop closing - had to be started over from R: the engine has hadresumesince ferx-core #1143 and the CLI has exposed it as--resume, but the R entry point did not. It now does.resume = TRUE(which needsdirectory) refits only the sample indices that directory’sraw_results.csvdoes not already carry, and reuses the base fit rather than refitting it.A resumed run is not an approximation of the uninterrupted one, it is the same run: a replicate’s draw is a pure function of
(seed, index), so a reused replicate is bit-for-bit the one a fresh run would have produced. The engine refuses to resume from a directory belonging to a different run - the model and data hashes, the parameter names and the settings that shape the replicates (samples,seed,sample_size,stratify_on,run_base_model,update_inits,keep_covariance,dofv) are all recorded next to the replicates and checked first. All of them are pinned, so a resume continues a run rather than extending it: a largersamplesis an error, not a top-up.retry_failed = TRUE(needsresume = TRUE) refits the replicates a previous run recorded as failed instead of carrying the failure forward. Off by default, matching PsN: a fit that failed usually fails again, so this is for a transient failure - an out-of-memory kill, a full disk - not a model one.# Interrupted at replicate 137 of 200 bs <- ferx_bootstrap(ex$model, ex$data, samples = 200, seed = 1, directory = "warfarin-bootstrap", resume = TRUE)ferx_bootstrap(progress = TRUE): a progress bar while the replicates fit. A 200-sample bootstrap is minutes to hours behind one call, and until it returned there was nothing to say whether it was working or wedged. The engine now reports each fit as it completes and R draws it - a cli progress bar when the cli package is installed,utils::txtProgressBar()otherwise. Defaultinteractive(), so a script or a knitted document is unaffected.The bar reports what the run will actually do: the base model gets a spinner of its own (a bar sitting at 0/200 through a single fit reads as stuck),
dofv = TRUEgets a second bar for its second pass over the replicates, and a resumed run counts only the replicates still to fit. Fitting happens on a worker thread while this R session draws, so nothing in the run waits on the bar, and a run watched and the same run unwatched produce the same numbers from the same seed.ferx bootstrapon the command line grows the same bar (--no-progressto suppress it).bs <- ferx_bootstrap(ex$model, ex$data, samples = 200, threads = 8) #> Bootstrap replicates [=========> ] 97/200 | ETA 1m 28sferx_gam_screen(): GAM-based covariate pre-screening. For each ETA x covariate pair fitseta ~ f(cov)and ranks covariates by AIC improvement over the null model (delta_aic = AIC_null - AIC_best). Functional forms tried: linear, natural cubic spline (df = 2 and 3 by default), and one-hot categorical. Warns when ETA shrinkage exceeds 30%. All numerical work happens in the engine, viaferx_tools::gam::gam_screen().ferx_bootstrap(): the non-parametric case bootstrap, at PsN’sbootstrapfeature parity (ferx-core #1144, engine side ferx-core #1140). Resample whole subjects with replacement, refit the model to each replicate, and get bias, bootstrap standard errors and both intervals - the percentile one (ci_lower/ci_upper) and the normal-approximation one built from the bootstrap SE (ci_lower_normal/ci_upper_normal) - side by side, so the disagreement between them is visible. Unlike the covariance step it survives a failed or non-positive- definiteR^-1, and it does not assume a symmetric interval.bs <- ferx_bootstrap(ex$model, ex$data, samples = 200, seed = 12345, threads = 8) bs$parameters # one row per parameter bs$raw # one row per fit, the original dataset first bs$diagnostics # run counts, exclusion tallies, diagnostic means plot(bs) # histogram per parameter (+ the dofv panel when dofv = TRUE)Supports stratified resampling (
stratify_on), PsN’s per-stratum-sample_sizespelled as an R named vector (sample_size = c("1001" = 12, "1002" = 24)),dofv,keep_covariance, and the four exclusion filters.directorydefaults toNULL, where the CLI writes{model}-bootstrap/: an R user has the data frames in hand and rarely wants eight CSVs appearing in the working directory. Set it to get the artefacts - and to be able to callferx_bootstrap_summarize()later.ferx_bootstrap_summarize()re-computes a finished run’s statistics from itsraw_results.csvunder different exclusion criteria - PsN’s-summarize. Refits nothing: it is the recovery path for a run where too many replicates were filtered out to resolve a percentile interval.Sample-size-weighted inter-occasion variability is now declared on the
kappaitself (ferx-core #1031, ferx-core #1062). Between-treatment-arm variability (BTAV) - the arm-level random effect in every published longitudinal MBMA - is distributedkappa_ik ~ N(0, gamma^2 / N_ik): a 400-subject arm’s mean wanders a quarter as far as a 25-subject arm’s. Until now the only way to express that in ferx was to write the divisor into a structural expression by hand (... + KAPPA_EMAX / sqrt(NARM)), where/ NARMinstead of/ sqrt(NARM)produces a plausible wrong answer rather than an error. It is now declared where the rest of the variance structure is:[parameters] kappa KAPPA_EMAX ~ 2.0 (sd) weight = NARM [individual_parameters] LEMAX = LEMAX0 + log(OR_ABATA) * ABATA + ETA_EMAX + KAPPA_EMAXThe engine applies the weight by reparameterisation, so the estimate in
fit$omega_iovstays the unweightedgamma^2- the quantity a published analysis reports - and so do the kappa EBEs and their shrinkage. On the R side:print()on the fit annotates the weighted kappa with the effective between-arm SD at a typical arm,gamma / sqrt(W):KAPPA_CL = 1.840240 (CV% = 230.2) SE = N/A Shrinkage = 37.4% weight = NARM -> SD = 0.0678 at NARM = 400.0000ferx_model_inspect()reports the weight both pre-fit (from the model file) and post-fit (from what the engine parsed), asIOV: KAPPA_CL (weight = NARM), and returns it in$iov_weights.ferx_fit()results carrykappa_weights(the weight expression per kappa,NAwhere unweighted) andkappa_weight_typical(the median weight over the dataset’s subject-occasions), and both survive aferx_save_fit()/ferx_load_fit()round-trip.
Models with no weighted kappa are unaffected in every one of those places: both fields are
NULLand the.fitrxbundle is byte-identical to before.Three follow-ups closed the gaps where the weight was still invisible:
ferx_model_inspect()on a model file now recognises the modifier by the same rules the engine’s parser uses (case-insensitive, whole-word, at bracket depth 0, on a single=). A model writtenWEIGHT = NARMfits with the weight applied but was reported pre-fit as unweighted, and a weight expression containing a comparison (weight = NARM * (FLAG == 1)) was truncated to1).summary()annotates the weighted kappa on itsIOV:line, asprint()andferx_model_inspect()already did. All three now share one helper, so they cannot disagree about whether a model is weighted.fit$estimatesgains aweightcolumn carrying the weight expression on a weighted kappa row andNAeverywhere else. Theestimateon such a row is the unweightedgamma^2, so a reader of the table alone had no way to tell it from an ordinary kappa and would takesqrt(estimate)for the between-occasion SD instead ofsqrt(estimate / W).
ferx_model_inspect()no longer depends on the case of a declaration keyword. The engine’stheta/omega/sigma/kappadeclaration regexes are all case-insensitive, so a model writtenTHETA TVCL(1.0, 0.001, 100.0)fits exactly like the lowercase spelling - but the R-side reader matched case-sensitively and reported no population parameters, no IIV and no IOV for it: an entirely blank structure for a model the engine parses fine.As a guard against the next such drift, a
[parameters]block that has content but not one recognised declaration in it now warns instead of silently returning an empty structure. It is keyword-agnostic, so it fires on whatever the next divergence turns out to be. No bundled example triggers it.ferx_simulate()andferx_predict()now accept a design template whoseDVcolumn is empty (#286, ferx-core #957). Dosing plus sampling times withDV = "."/NA- the natural way to write a design, and what NONMEM’s$SIMULATIONaccepts - previously returned zero rows: every observation record with a missingDVwas dropped as a forgottenMDV = 1, which is the right reading only when theDVis an input. Simulation and prediction both produce that column, so such a record is now read as a sampling time whose value is about to be generated, and no placeholder number is needed.MDV = 1still excludes a record, and fitting is unchanged. Applies toferx_simulate()(both the default-parameter and thefit =path),ferx_simulate_adaptive(),ferx_simulate_with_uncertainty(), andferx_predict().Because the simulate and fit readers now disagree on the same file, a kept empty-DV record is counted and reported: the returned object carries the count in its
simulation_warningsattribute, and it is re-emitted as an R warning. A dataset with an accidental missingDV(rather than a deliberate design) would otherwise silently gain simulated rows at timesferx_fit()’ssdtabhas no observation for - biasing a VPC built by overlaying the two.Models with no random effects - fixed-effects-only (naive-pooled) fits (ferx-core #989). A continuous model may now omit every
omegadeclaration. Withn_eta = 0there is no inner empirical-Bayes problem and nolog|Omega|term, so FOCE/FOCEI collapse to plain maximum likelihood: every subject shares one set of parameters andsigmaalone carries the spread.PREDequalsIPREDandCWRESequalsIWRES. An[error_model]and itssigmaare still required - only Omega is optional. New bundled exampleferx_example("one_cpt_iv_pooled"), anchored against a NONMEM 7.6.0 run of the same model written as$OMEGA 0 FIX(OFV -269.6370, TVCL 4.8408, TVV 52.834).TIMEnow works in a[scaling]Form C readout (ferx-core #1028). Ay = <expr>/y[CMT=N] = <expr>readout referencingTIMEparsed fine but was never bound to the observation, so it read0at every row and the whole time-dependent term vanished — a response-versus-time readout such asy[CMT=1] = EMAX * TIME / (TIME + T50)therefore fit, converged, and reported plausible parameters for a structural model nobody wrote.TIME(and theTalias) now resolves to each observation’s own time on both the ODE and analytical paths, and to each decision’s time in[adaptive_dosing] observe. The dummyd/dt(clock) = 1workaround is no longer needed, and is better dropped:clockstarts at the subject’s first record, not att = 0. The readout’sTIMEis the raw data-file clock — the one sdtab,ferx_predict()/ferx_simulate()and[derived]windows report.New
mstep_dampingsetting for SAEM (ferx-core #1011). SAEM’s numerical theta/sigma M-step assigned the maximiser outright, re-maximising against a single MCMC eta draw each iteration —argmaxof one draw rather than the stochastic-approximation average — a Monte-Carlo bias that does not decay with iteration count. It bit only a theta left to the numerical M-step without a mu-reference; a log-mu-referenced theta was already exact. The M-step result is now blended in astheta <- theta + gamma * (theta* - theta), withgammacapped at0.03during exploration. Passsettings = list(mstep_damping = ...)to change it;1.0disables the damping entirely and restores the previous behaviour exactly. A fit whose every estimated theta is mu-referenced,FIXed or pinned out is bit-identical to before — both bundled SAEM examples (warfarin_saem,warfarin_iov_saem) are log-mu-referenced throughout and are unchanged. SAEM also now warns when an estimated theta carries no ETA at all, which is the shape the bias was measured on; attaching an ETA or holding the parameterFIXremains the better fix.The ODE solver
settingsnow warn when the model never integrates (ferx-core #518).ode_reltol/ode_abstol/ode_max_steps/ode_method/ode_stiff_abort_after/ode_auto_switchon an analytical PK model with no[odes]block and no closed-form absorption ODE twin were dropped in silence; they now come back as an unused-option warning. A model that does integrate — including a transit / inverse-Gaussian model reaching its twin — still never warns on these keys.nn_l2andnn_smoothregularization for the covariate NN (DCM) (ferx-core #1215). Two newsettingskeys, both defaulting to0.0(off, a strict no-op), adding L2 weight decay and a curvature penalty to a[covariate_nn]fit. Only available in a build with thenncargo feature enabled, which is off in the default package build.
Bug fixes
fit$individual_estimatesreportedCL’s value for an unbound analytical parameter, and class-1 values for every subject of a mixture model (#368, ferx-core #1356). The table resolved each parameter’s value through the engine’s PK-slot map. On an analytical (pk ...) model a top-level[individual_parameters]name that the[structural_model]line does not bind - an intermediate such asTVCL = THCL * 3, or a modeled doseD{n}/R{n}- has no slot of its own and carried a placeholder entry pointing atCL’s slot, so its column heldCL’s value. On the bundledtte_exponentialexample every subject’sLAMBDAcame back as1(the FIXedDUMMY_CL) instead ofTVLAMBDA * exp(ETA_LAMBDA). Under the idiomaticCL = TVCL * exp(ETA_CL)an intermediate equalsCLat eta = 0, so the wrong read returned the right number and the defect rarely showed on inspection. Values now come from ferx-core’s by-name API. ODE models were never affected.The same evaluation ran with the mixture class left at its default, so on a
[mixture]model -CL = if (MIXNUM == 1) TVCL1 else TVCL2- every row held the class-1 typical value. Each row is now evaluated in that subject’s own fitted class (theMIXESTcolumn ofsdtab).The table is one row per subject, so a parameter that reads the
TIMEbuilt-in is evaluated atTIME = 0; this is now documented in?ferx_fit.A
logit_probabilitytheta was back-transformed a second time on the way out of the fit, and its confidence interval was not on a probability scale (#371).logit_probabilityis the one transform label that means “the engine reports this theta already on (0, 1)” - that is the point of the parameterisation - but.ferx_est_row()handled it in the same branch aslogitand appliedinv_logit()to it. The engine was right and correctly labelled throughout; only the reporting was wrong. On the bundledbioavailabilityexample the estimated bioavailability is0.7923592, andfit$estimatesreportedestimate_natural0.6883377; the same model written onto a genuine logit-scale theta agreed with the engine, so the two parameterisations of one model disagreed by 0.104 of a probability.print()compounded it, tagging the row[logit scale]and printing a(typical)line of the same double-transformed value - onwarfarin_logit_f, whose typicalFis0.0126, it read0.5032with a 95 % CI of[0.3339, 0.6717].Such a theta is now reported as what it is.
estimateandestimate_naturalare both the probability.lower_95/upper_95stay the symmetric Wald interval on the reported scale, as for every other transform, which on a probability is not constrained to (0, 1). The natural-scale interval is formed where the parameter is unbounded instead: the standard error is carried to the logit scale by the delta method,se / (p (1 - p)), and the symmetric interval there is brought back throughinv_logit(). Onbioavailabilitythat is[0.16503, 0.98661], against alower_95/upper_95of[0.30528, 1.27944]- the upper bound being a bioavailability of 128 %. Written on alogittheta the identical model reports[0.16547, 0.98657], so the two parameterisations now agree on every natural-scale column rather than only on the point estimate.print()emits neither the scale tag nor a(typical)line for such a theta- the row above already carries the probability - but gains a
(95% CI)line holding the interval above, which the old(typical)line’s CI was standing in for.
The
+/-1SDrange printed beside alogit_probabilityOMEGA row is fixed with it: the eta is on the logit scale but the linked theta is not, so the theta is now put on the logit scale before the eta SD is added. Onbioavailabilitythat range moves from[0.598, 0.766], which did not contain the estimate, to[0.720, 0.850], which does. Which conversion applies is decided by the theta’s owntransformrather than by the eta’s label, since it is the theta whose scale is in question. A theta outside the open interval, where the logit is not finite, printsSD_logitalone rather than a range.For users: a script reading
estimate_naturalfor alogit_probabilitytheta previously gotinv_logit(estimate)and now getsestimateitself, andlower_95_natural/upper_95_naturalmove correspondingly. Nothing was added or removed fromfit$estimates; the values change. The correction also reaches existing.fitrxfiles, sinceferx_load_fit()recomputes the table rather than storing it.vignettes/articles/parameter-transforms.Rmddocumented the old behaviour as intended and has been rewritten against a real fit.- the row above already carries the probability - but gains a
NPDE / NPD now sample the occasion
kappa(ferx-core #734). The post-fit Monte-Carlo reference distribution held everykappaat zero, so for an IOV model it carried no between-occasion variability and the scores came back over-dispersed - a well-specified IOV model looked mis-specified. The reference now draws one independentkappa ~ N(0, Omega_IOV)per occasion, matching whatferx_simulate()already did, anchored row-by-row against NONMEM$TABLE ... NPDE NPD ESAMPLE=. Non-IOV models are unchanged.A
kappamodel’s reported OFV was often far above the value the optimizer had actually minimised (ferx-core #1327). The IOV inner loop discarded a converged-in-all-but-name BFGS solution for a much worse Nelder-Mead restart from the cold seed, so every cold-started evaluation- the reported final OFV, a
maxiter = 0re-evaluation, a.fitrxreload - scored some subjects thousands of -2LL units too high (9,300 on a[covariate_nn]+ IOV busulfan fit). Reported objectives for IOV models move.
- the reported final OFV, a
floor(x),ceil(x)andround(x)now differentiate to0instead of tox’s own derivative (ferx-core #1332). An[individual_parameters]or[odes]expression that rounds - a dose-band lookup, an occasion index derived fromTIME- was feeding a wrong analytic gradient to the estimator while its value path was correct.A
threadsbudget is now a real ceiling on how many fits run at once inferx_bootstrap(),ferx_modelsearch(),ferx_covsearch(),ferx_iivsearch()andferx_ruvsearch()(ferx-core #1329). Each replicate or candidate runs its own fit on a nested thread pool, and a worker blocked on that nesting kept taking more work, so a run asked for 4 concurrent fits could hold far more - and that many fits’ worth of peak memory. Runs already inside their budget are unaffected; heavily oversubscribed ones should see lower peak memory rather than higher throughput. An unset bootstrap thread budget now preserves the ambient Rayon pool width, includingRAYON_NUM_THREADS(ferx-core #1330).print()of aferx_modelandferx_model_inspect()on a model path now list etas declared in ablock_omega(#358). The structure summary built before a fit read IIV names only fromomega NAME ~ ...lines, soferx_example("warfarin_block_omega")printedIIV: ETA_KAinstead ofETA_CL, ETA_V, ETA_KA, and everyferx_model_to_frem()result, whose etas all sit in one block, printedIIV: none.block_kappanames are now read for IOV the same way, withiov_weightskept one entry per name andNAfor every name of ablock_kappa, which cannot carry aweight =modifier. A block header is read only when it carries the engine’s= [, so a line the engine parses as valid and ignores no longer contributes etas that the fitted model does not have.ferx_model_show()also highlightsblock_kappaandblock_sigmanow. Post-fit output (fit$model_structure) was not affected: it comes from the engine.ferx_model_to_frem()now writes its files tooutput_dir. Unlessoutput_modelandoutput_datawere both given,output_dirwas created and then ignored: the generated<stem>_frem.ferxand<stem>_frem_data.csvwent next to the model file. For aferx_example()model that is the installed package library, so the call wrote into the library, or failed where the library is read-only. The default paths are now built inoutput_dir, as?ferx_model_to_fremdocuments. An explicitoutput_modeloroutput_datastill takes precedence for that file, andoutput_diris neither read nor created when both are given. The generated paths are made absolute, so a relativeoutput_dirno longer returns aferx_modelthat resolves only from the working directory the call was made in. Whendatais omitted, it now falls back to the model file’s[data]block, as inferx_fit(), instead of erroring.print()of a fit no longer lists uncorrelated random-effect pairs under “Correlations” (ferx-core #1018). The section opens when any covariance is non-zero, and it then printed every pair, so ablock_omega (ETA_CL, ETA_V)declared beside a diagonalomega ETA_KAalso showedETA_KA ~ ETA_CL : cov = 0.000000 ... SE = 0.000000. Pairs with a zero covariance are now skipped, for OMEGA and for OMEGA_IOV, with the same threshold the engine’s own summary and YAML output use.fit$individual_estimatesreports the right values for ODE models. Each column was read from PK slot i for the i-th[individual_parameters]declaration, but the engine does not store ODE parameters in declaration order: canonical names (CL,V,KA,F,LAGTIME, …) sit at their fixed slot and every other name takes a free slot that is not reserved forF/LAGTIME. Columns therefore showed another parameter’s value or an unwritten0-KAandLAGTIMEwere0onwarfarin_ode_lagtime,KAwas0onwarfarin_ode, andtransit_savicreported theTVNestimate asKA,0asMTTand theTVKAestimate asNTR. The table now reads each parameter through the engine’s per-parameter slot map, the same one the ODE right-hand side reads from; onwarfarin_ode_lagtimeandtransit_savicevery value equals the model’s own formula evaluated atfit$thetaandfit$ebe_etas.The table also no longer carries columns named
__ferx_ro_*or__ferx_pktime_*. The parser adds these internal parameters when a[scaling]readout refers to athetaoretadirectly, or whenpk(...)binds a parameter toTIME, andferx_xpose()listed them as parameter columns.Still wrong on analytical models: a top-level
[individual_parameters]name that is not bound on the[structural_model]line (an intermediate such asTVCL, orLAMBDAin the bundledtte_exponential) reports CL’s value. The same wrong value appears in an[output]echo of that name. This needs an engine change and is tracked in ferx-core #1356.Two consumers read this table and were wrong on ODE models for the same reason; both are corrected by the same change.
ferx_xpose()joins it into the Xpose data as the parameter columns, and overwrote a correct[output]echo of the same name, so echoing parameters did not work around the bug there.ferx_cov_screen()computed itsebecolumn from it: the association came outNAfor a parameter that read as0and was computed against another parameter’s values otherwise, which could also add or drop rows at thethreshold. Itsetacolumn and parameter labels were unaffected.ferx_get_warnings()no longer recommends solver settings for ODE problems they cannot fix (ferx-core #1234, ferx-core #1204). The guidance printed under everyode_solverwarning said to setode_methodor adjustode_abstol/ode_reltol/ode_max_steps. Two of the problems that warning reports are not solver-setting problems, and the engine’s clause for each says so. A subject whose timeline could not be ordered (aNaNor infinite dose time, lagtime, route lag or infusion duration) was never integrated. A segment whose analytic sensitivities overflowed was integrated without trouble by the stiff method; its derivatives, not its values, were too large. The guidance now points at the dose records and any covariate model onALAG/F/D/Rfor the first, and at the model’s units and scaling for the second. The solver-setting advice is kept for the clamped, discarded, unfinished or aborted segments it does apply to, including when one warning reports both kinds.The engine’s structured
detailsfor this warning (every counter, by name) does not reach R, so the guidance tells the clauses apart by message phrases, each checked against the engine source at the pinned revision.An ODE-accumulated hazard that reads
TADorTAFDno longer evaluates toNaN(ferx-core #1261, #1266). The hazard readout re-evaluated the ODE right-hand side with the bare PK parameter array, which ends at the last PK parameter and omits the two trailing slots the RHS reserves for the dose-time anchorsTAFDandTAD. Ahazard =expression that reads either one - or that depends on a statement which does - therefore saw a non-finite hazard, which surfaced much later as a misleading finite objective and could reject valid subjects, single-dose ones included. Only the hazard is affected: the readout keeps the cumulative-hazard slot alone, so an[odes]block readingTADbeside aTAD-free hazard always evaluated correctly. The readout now passes the same extended parameters the integrator itself uses. This reachesferx_predict_survival()and joint PK-TTE fits whose[event_model]hazard accumulates on an ODE state. No bundled example is affected: no TTE model ininst/examples/models/readsTADorTAFD.A
.tmpcheckpoint from a deterministic stage now holds the best point, not a throwaway probe (ferx-core #1317).foce,focei,laplace,gnandgn_hybridevaluate the objective at every point the optimizer probes, so a checkpoint write landing mid-line-search recorded a rejected trial point: on a[covariate_nn]FOCEI fit plateaued at OFV 51786 the checkpoint held OFV 2.76e6. Anything reading the checkpoint as “where the fit is” — a resume, a progress monitor, a scorer — saw a point orders of magnitude off. Asaemstage is unchanged: it deliberately saves its latest state, which is what a correct continuation of the chain resumes from, so a consumer comparing checkpoints must readmethod_chain/stage_idxfirst.A
thetawhose declared range cannot be represented no longer aborts the fit (ferx-core #1251).theta TVCL(1.0, 5.0, 2.0)(bounds swapped) andtheta TVCL(1e-12, 1e-13, 1e-11)(a range wholly below the engine’s internal packing floor) both produce an empty optimizer box, and the bound clamp used to panic on it. Both now reportE_INIT_BOUNDS_INVERTED, naming which cause applies, and only the affected coordinate is silenced soferx_model_validate()still reports the rest of the file in the same pass.A
CMT=0infusion no longer reports a spurious lag-time gradient (ferx-core #1077).CMT=0is NONMEM’s default bolus compartment and has no rate channel, but when such a dose also carried a lag time the analytic sensitivity walk still fired the infusion-end saltation, reporting a finited f / d eta_LAG(+1.89 at the first sample past the window end, against a central-difference reference of exactly0.0) for a subject that receives no drug. Reachable only from a hand-built model spec that runs no validation, so no validated fit changes.W_INIT_OUTSIDE_BOUNDSfor asigmanow names the scale its numbers are on (ferx-core #1251). The engine stores sigma as a standard deviation and square-roots a plainsigma X ~ vdeclaration, so the quoted number is an SD that need not appear in the model file:sigma PROP_ERR ~ 1e6now readsan SD of 1.000e3rather thana value of 1.000e3.A model carrying covariate thetas no longer stalls at its initial estimates (ferx-core #1290, fixed in the engine by anchoring the EBE warm start to the best point seen). The pinned
ferx-core/ferx-toolsrevision moves to19bf7cfto pick this up. It is what madeferx_covsearch()unusable on the bundled example - every candidate was rejected as an init stall - so the search now selects on the statistics rather than on a numerical failure. The same bump bringsELIMINATIONinto the engine’s covered feature set (ferx-core #1257), whichferx_search_coverage()reports.An infusion into a built-in absorption compartment is no longer delivered twice (ferx-core #1187). A
RATE > 0orRATE = -2dose into the absorption compartment of an ODE model had its rate applied both through the absorption kernel and, a second time, as a plain input rate straight into the target compartment - so the dose was double counted and the second copy bypassed absorption entirely. On a mass-balance readout the excess is exactly2 * F * amt; on a concentration readout it is worst early (214x at t = 0.5 on a transit model) and settles near 1.86x once both copies have distributed.Six surfaces were affected: ODE
IPREDand the state columns insdtab, the cumulative hazard andferx_simulate()event times of a joint PK-TTE model, the CTMM likelihood, and[derived]grid integrals. The event-driven path returned a correctIPREDbeside wrong states, so an sdtab row could disagree with itself. Bolus dosing, and any model whose infusion targets a central compartment, were never affected.ferx_get_warnings()now answers a failed covariance step with the advice it was written to give. Every targeted branch of the covariance guidance - a non-positive-definite Hessian with its eigenvalue list, ill-conditioned Hessian entries naming the parameter, a near-singular omega, a non-finite base OFV, and the minor/moderate/severe regularisation tiers - sat behindcategory == "covariance_step". ferx-core does not code those messages that way: it codes themcovariance_failedandcovariance_regularized, and reservescovariance_stepfor an informational note about the step’s cost. So the whole block was unreachable and a failed covariance step printed no guidance at all, while the one message that did reach it - the benign cost note - was answered with “Standard errors unavailable. Check identifiability”, reporting a failure that had not happened.Guidance is now selected by the message, which is what those branches were always keyed on. Four categories reach it: ferx-core’s three, plus the
covariancecategoryferx_covariance()assigns to the same engine messages, so the post-hoc covariance step gets guidance too. A covariance message arriving undergeneralis answered as well, which covers every fit read back withferx_load_fit()- it does not restore the structured table, so all its warnings arrive under that category. Admission is anchored to messages that begin with “Covariance step”, so SIR’s diagnostics, which mention the step in passing, keep their own guidance instead of being told to re-run withcovariance = FALSE- the one setting that removes the matrix SIR needs.Three messages that were being answered wrongly now have their own advice: an off-diagonal FD stencil that could not be evaluated, which ferx-core reports on its success path (the standard errors exist and are merely over-optimistic, not missing); a covariance step cancelled part-way, which produced no standard errors but diagnosed nothing about the model; and the informational cost note. A near-singular omega now reaches the omega branch too - ferx-core picks that descriptor from the sign of the smallest eigenvalue, and the guidance had matched only the other one.
The unused-parameter warning gets its guidance back. ferx-core has no
unused_parametercode - its unused-declaration messages classify togeneral- so the guidance written for them was unreachable in the same way the covariance block was. They are now routed by message. ferx-core’s flat-theta warning contains the same “computed but never used” phrase and is excluded explicitly, so it is not answered with the unused-parameter text.ferx_sir()now reports the engine’s SIR-step warnings instead of dropping them (ferx-core #1021). The binding returned only the CIs and the ESS, so the proposal diagnostics ferx-core emits - a covariance that is rank-deficient beyond itsFIXed parameters, or a proposal direction shrunk to keep draws inside the parameter bounds - never reached the fit. They now land infit$warningsand infit$warnings_structuredunder thesircategory, andferx_get_warnings()answers them with guidance about the model (the named parameters are not identified by the data, and their SIR intervals understate the uncertainty) rather than the old “raisesir_samples” advice, which does not help in that case. The same ferx-core release stops those fits from failing outright withAll SIR samples had invalid weights- which is what every model-based meta-analysis hit, since fixing the residual variance is the inverse-variance weighting scheme and cannot be dropped.ferx_covariance()andferx_sir()no longer reject a fit that has no random effects (#290). Both required a non-emptyfit$ebe_etas, which a fixed-effects-only fit does not have - there are no EBEs to warm-start from - so both stopped with “fit\(ebe_etas is empty; cannot warm-start the inner loop". They now pass an empty warm start through. This matters for naive-pooled models in particular: NONMEM's `\)COVARIANCEdefault is the RSR sandwich, andferx_covariance(fit, covariance_method =”rsr”)is how you reproduce it from R. On such a model the sandwich runs about twice as wide as ferx's default“r”`, because the model ignores within-subject correlation by construction.print()on a fit with no random effects no longer prints an emptyOMEGAsection header (#290). An empty header over an empty body reads as an estimation that failed rather than one that was never requested.ferx_simulate(..., fit = f)works again for models with inter-occasion variability (ferx-core #1019). The fitted IOV covariance (fit$omega_iov) was never passed to the engine, so any model declaring akappacrashed the R session withomega_iov is present whenever the model declares kappa (n_kappa > 0)instead of simulating.fit$omega_iovis now threaded through every from-fit entry point (ferx_simulate(),ferx_simulate_with_uncertainty(),ferx_calc_npde(),ferx_predict(),ferx_predict_survival()).ferx_calc_npde()simulates internally and hit the same crash;ferx_simulate_with_uncertainty()silently drew around the model file’s initial IOV variance instead of the fitted one, and now uses the fitted value.An adaptive-dosing
dvmonitor no longer floors a negative Form C[scaling]readout at zero (ferx-core #1039, ferx-core #1020). The assay floor (“an assay cannot read below zero”) was applied unconditionally after the residual draw. A Form Cy = <expr>readout is an arbitrary expression — a change from baseline, a difference from a comparator, a z-score — so the same model read correctly undermode = "ipred"and came back as exactly0undermode = "dv"for every negative sample, silently: a controller thresholding a change-from-baseline signal saw0over precisely the region it was written to react to, and dosed accordingly. The floor is now gated on the same predicate as the prediction path, so it applies only to the bare-state readout and to Forms A/B, which keep it.Engine warnings now carry an
absorption_twin_declinedcategory (ferx-core #1008). A transit or inverse-Gaussian model that cannot build the ODE twin it falls back on now says so at parse time, with its own reason, instead of declining silently. It reachesfit$warningsandfit$warnings_structuredlike any other engine warning. Noteferx_get_warnings()prints the category but has no remediation text for it yet, so it shows without the guidance block other categories get.A dose landing within 1e-12 of a derived break time is no longer applied twice (ferx-core #1186). A per-route absorption onset (
dose.time + ALAG + lag) or an infusion end (dose.time + AMT/RATE) is a multi-term float sum, so it routinely lands an ULP or two from another dose’s own break and the dose fired at both: a bolus was doubled, and a colliding infusion ran at double rate for the whole window. The dose / SS-seed / reset match is now one tolerance across every engine — it used to be looser on the sdtab, joint PK-TTE hazard,[derived], Markov andferx_simulate()paths than on the objective, so the same dataset could double a dose in every diagnostic while the reported OFV was correct.A non-finite dose lagtime or bioavailability no longer aborts the fit (ferx-core #1189). A
NaNor infiniteALAG/LAGTIME— typically an exponential covariate model on an unscaled covariate — made the subject’s integration timeline unorderable and the fit died withcalled Option::unwrap() on a None value. Such a subject now comes back non-finite, which the estimator already handles as a diverged solve, and anALAGorFthat is non-finite at typical parameter values is rejected before the fit starts withE_DOSE_ATTR_NONFINITE, naming the subject.ferx_fit()now warns when an ODE subject’s timeline cannot be ordered (ferx-core #1234). ANaNor infinite dose time, lagtime, route lag or infusion duration makes the engine abandon that subject’s integration before the solver starts, so its predictions areNaNby construction - but every counter in theode_solverdiagnostic read zero, exactly as for a subject with nothing to integrate, so the fit could returnofv = NaNwith no warning naming the cause. A fit that hits this at the final estimates now emits anode_solverwarning counting the abandoned solver walks (walks, not subjects: a subject’s predictions and its[odes]state readout are separate walks) and pointing at the dose records and any exponential covariate model onALAG/F/D/R. Follows on from ferx-core #1189 above, which stopped such a subject aborting the fit.A joint PK-TTE subject whose
TENTRYfalls at or before its first record no longer scores the divergence sentinel (ferx-core #1223). Whether such a subject was repelled or scored depended on which internal engine it was admitted to. Both now agree withferx_predict_survival():H = 0andh = h(u0)there, so a pre-start entry time contributes nothing.A joint PK-TTE / binary / Markov model fed a population read without the model is now a hard error (ferx-core #1199). An unrouted read carried the endpoint’s rows as Gaussian observations and no event records, so the fit ran the Gaussian half only and reported a plausible, finite, wrong objective.
E_ENDPOINT_UNROUTEDnow names the CMT instead; the guard also coversferx_covariance()andferx_sir(), which previously computed on the Gaussian half of a joint likelihood.ferx_fit()also rejects a routed population whose declared endpoint has no rows at all — typically a missing or mis-mappedCMTcolumn — withE_ENDPOINT_NO_RECORDS; andferx_sir()’s re-read of the fit’s data now honours[data]column renames (ferx-core #730), so a model that mapsTIME = TAFDresolves it the way the fit did instead of failing or reading the wrong column (ferx_covariance()already did).ferx_model_to_frem()now refuses a model with a non-Gaussian endpoint (ferx-core #1199). A joint PK-TTE / binary / Markov model came back with a FREM dataset built from the Gaussian rows alone; it now errors withE_FREM_NON_GAUSSIAN_ENDPOINT. Run the FREM step on the PK model without the endpoint block.
Documentation
?ferx_simulatenow says which predictive distribution it produces (#299).ferx_simulate()draws a fresh set of random effects for every ID in the data in every replicate and never conditions on a subject’s own observations, so what the spread ofDV_SIMis a distribution of follows from what one ID means in the data and at what level the etas were estimated. In individual-level PK an ID is a patient and the two coincide; in a model-based meta-analysis a row is a trial-arm summary, an ID is a study, the etas are between-study, and each replicate is a set of new studies - the predictive distribution of the next trial’s readout, not of the next patient. A new section spells that out, separatesIPRED(drawn random effects, no residual error) fromDV_SIM(plus residual error), and points atferx_predict()for the typical-value curve andferx_simulate_with_uncertainty()for parameter uncertainty on top.cov_inner_tol/ covariance-key docs now match the pinned engine (ferx-core #956). In ferx-core7f15dba,cov_inner_tolmoved to the advertised covariance settings set, so the old warning about it being ignored is removed; the key is required to be positive and finite, and the five covariance-step keys are inert undermethod = "bayes"(which reports posterior credible intervals rather than Hessian standard errors). In that mode, the engine warns that each key configures a step that does not run and ignores it.This documentation update is written against the
7f15dbabehavior, which the pinned engine now includes:7f15dbais an ancestor of the pinned revision, so a build against this repository rejects a non-positive or non-finitecov_inner_toloutright rather than warning about it.Two smaller corrections in the same area.
covariance_fallbackandferx_covariance()both described the matrix they rectify as the “FD Hessian”, which stopped being true of the default path when the analytic R-matrix landed (analytic_cov_hessian = TRUE); the fallback handles a non-positive-definite covariance Hessian from either source. Andglobal_maxeval’s default0was described as disabling the global-search budget, when it in fact selects an automatic one of30 * (n_params + 1).This
7f15dbachange is independent of the existing0.3.1->0.4.0release-line work already in this repository. It needed no lockfile bump of its own: it arrived with the pin move to944cbf1e, which already contained it.
Internal
The pinned engine revision moves
8694824->8372248c, withferx-coreandferx-toolsboth staying at0.4.0(one repository, one revision, two lock entries). The range is 29 commits; the user-visible ones are written up under Breaking changes, New features and Bug fixes above. The rest are engine performance work with no R-visible contract change: thelaplacegrid-response gradient is assembled analytically by default and no longer rebuilds the conditional Hessian per parameter (ferx-core #1335), its1/2 log|H|sweep walks only the random-effect axes (#1342) and reuses the anchor’s sensitivity jet (#1344),foceiwithn_agq > 1contracts the grid response once per subject (#1333), the post-fit diagnostics pass runs in parallel over subjects (#1329), and SAEM’s per-occasionkappasampling is parallel and bit-identical. On ODE models thelaplaceoptimizer path can change - the two routes differ at ~1e-10 - so a converged estimate may shift within the convergence tolerance; closed-form models were unaffected andfoceiis untouched.tools/update-ferx-core-lock.shmoved no registry crate this time: the diff is the twosource = "git+..."lines and nothing else.The Rust glue gained the three
FitResultfields ferx-core added for priors (ofv_data,ofv_prior,prior_summary; ferx-core #254). This package buildsferx_core::FitResultwith exhaustive struct literals in three places - the scaffold behindferx_simulate_with_uncertainty(), and the skeletons handed torun_sir()andrun_covariance()- so the added fields broke the build withE0063until each was filled in. All three carry the unpriored values: the whole objective on the data half,0.0prior, empty summary. Neitherrun_sir()norrun_covariance()reads the three fields - SIR takes its reference objective asofv - ofv_priorand the covariance step re-derives any prior curvature frommodel.priors, not from the fit.FitResultderives noDefault, so these literals cannot be spread the way theR-CMD-checkworkflow requires every*Optionsliteral to be (ferx-core #529). Every future ferx-core field addition therefore breaks the build here, with no gate to catch it before the bump.The degenerate oracle from #332 is now covered for
ferx_covsearch()andferx_allometry(). The other four search tools each had one; these two shipped without. covsearch has no single-point space of its own - a structural feature is refused outright - so the degenerate case asserted is a space whose one candidate cannot be selected: at an alpha no dOFV can clear, nothing is included, the candidate is still a row with its verdict, and the returned fit matchesferx_fit()on the base model. Forferx_allometry()the anchor is the arm every dOFV it reports is measured against:res$base_fitmust be the base model fitted, not a second answer to the same question.Dependabot now ignores
ferx-toolsas well asferx-core. The two crates share one git source - the ferx-core repository, pinned at a single revision insrc/rust/Cargo.lock- so a Dependabot update of either one moves the pin for both. Onlyferx-corewas on the ignore list, so Dependabot’sferx-toolsbumps advanced the engine pin twice outsidetools/update-ferx-core-lock.sh: #319 (a861757->eb669c4) and #340 (19bf7cf->909ad38), neither with a NEWS entry. Both passed theR-CMD-checkpin guard, and its successortools/check-ferx-core-pin.sh(#349, below) passes such a bump too - each kept the git source and one shared revision - so the ignore list is the only thing that stops this. Of what they imported, ferx-core #1234 is the change users see, now written up under Bug fixes. The rest needs no entry: the engine’sferx gamCLI, review fixes to the GAM screen behindferx_gam_screen()(ferx-core #1114; the function is new in this development version), a narrow FOCEI analytic-gradient speed-up (ferx-core #829) and a test-only fix.The pinned engine revision moves
944cbf1e->8694824, withferx-coreandferx-toolsboth staying at0.4.0(one repository, one revision, two lock entries). The range is three commits, all of the ODE-accumulated-hazard fix above (ferx-core #1323). No public Rust API changed in the range, so this package’s glue is untouched. The barecargo updatethattools/update-ferx-core-lock.shruns also carries a transitivetoml1.1.5->1.1.6into the lock.One
Cargo.lockpin check, shared by CI and contributors (#349). With a sibling../ferx-corepatched in, every cargo resolve rewritessrc/rust/Cargo.lock-R CMD INSTALL .,roxygen2::roxygenize()andpkgload::load_all()included: a patch that applies deletes bothsource = "git+..."pins, one that goes unused appends[[patch.unused]]tables, andgit statusshows either as an ordinary modified file. TheR-CMD-checkpin guard moved intotools/check-ferx-core-pin.sh, which also rejects[[patch.unused]]tables and says how to repair each case, and a new CI step feeds it damaged locks so a deleted check fails the build.tools/update-ferx-core-lock.shcalls the same check and now runs in a fresh clone or worktree withoutsrc/rust/.cargo/config.toml.src/Makevarsno longer claims “using local ../ferx-core checkout” before cargo has decided anything - a[patch]applies only at exactly the locked version - and in a git worktree with no sibling it says the pinned revision is being built. Keeping the lock pinned during local builds is left to a follow-up.The pinned engine crosses a semver-breaking boundary:
ferx-core/ferx-tools0.3.1->0.4.0(revision909ad382->944cbf1e).ferx_core::types::CovariateFormgained aCategorical2variant and is now#[non_exhaustive], andCovariateRelation(andedit::Relation) gained anop: CovariateOpfield (ferx-core #1312, #1313).No R-visible API changes and no change to this package’s Rust glue. The glue matches
ferx_tools::gam::CovariateForm— a different, unchanged enum used for GAM screening inferx_gam_screen()— and never constructs aCovariateRelation;cargo check --no-default-features --features ci,nn,survivalagainst the new revision passes untouched. Anyone with their own Rust code matchingferx_core::CovariateFormexhaustively must add a_arm; from here on a new covariate form is genuinely additive.One local-development consequence: while the lock still pinned
0.3.1, the[patch]insrc/rust/.cargo/config.tomlstopped applying, because the sibling checkout had moved to0.4.0and cargo uses a[patch]only at exactly the version the lock pins — so a local build silently built the pinned GitHub revision instead of the sibling. Bumping the lock restores it;src/Makevarsregenerates the file with both[patch]entries on everyR CMD INSTALL.The engine gained
ferx globalsearch— global model search by genetic algorithm or exhaustive enumeration, ranked on pyDarwin-style penalized fitness (ferx-core #1185). There is no R binding yet;ferx_covsearch()andferx_modelsearch()are unaffected. (Unrelated to the existingsettings = list(global_search = TRUE)option onferx_fit(), which is a global optimizer phase within one fit.)ferx-toolsis now a second git dependency, from the same ferx-core repository and the same revision asferx-core- one extraCargo.lockentry, no second pin to track.src/Makevarswrites the matching[patch]line for it, so a local../ferx-corecheckout patches both crates: with only theferx-coreentry a local build silently mixed a working-treeferx-corewith a GitHub-mainferx-tools. TheR-CMD-checkpin guard andtools/update-ferx-core-lock.shnow check both crates and that they sit on one revision.Bumped the pinned ferx-core commit and updated the extendr glue for the
mixture/pmix/mixestfields added by ferx-core #977/#985 (#291).Bumped the pinned ferx-core commit to
25b5f473for ferx-core #993 (the dose-attribute double-use rejection above). No glue change: the new items on the Rust side are additive and unused here, sosrc/rust/src/lib.rsis untouched.Bumped the pinned ferx-core commit to
0f571d83(#304). Beyond ferx-core #1004 above, that range carries #1040, #1028/#1042/#1045, #1039/#1020, #1011/#1012, #1006/#1007, #1008, #1019 and #1021 — every user-visible one is written up in the sections above. No glue change.The ferx-core
*Optionsstructs are now spread-constructed in the glue (ferx-core #529, #330).SimulateUncertaintyOptionsandferx_tools::gam::GamOptionswere built with exhaustive struct literals, so the next field added to either in the engine breaks this build witherror[E0063]: missing field— the cross-repo trapCLAUDE.md’s sibling-repo note warns about, which already bit us once forSimulateOptions(ferx-core #522 / #200). Both now carry..Default::default(), and a newR-CMD-checkstep scanssrc/rust/src/*.rsfor an options literal without one — one-line and multi-line forms alike — so a future entry point cannot reintroduce the shape. Behaviour is unchanged: every field these entry points expose is still passed explicitly.Bumped the pinned ferx-core commit to
fdef70befor theDefaultderives above. That range also carries ferx-core #1186, #1189, #1223, #1199 (with theE_ENDPOINT_NO_RECORDS, FREM and[data]-rename parts it also fixed) and #518 — all written up in the sections above — plus #1178/#1179 (theferx-toolssearch runner and MFL parser, not yet wired to R), ferx-core #1196’s shared infusion membership rule and #1199’s routed.fitrxreload (both reachable only from the engine’s own entry points, not from R), and CI/docs-only work.
ferx 0.3.0
Breaking changes
CMT=0on an ODE dataset now predicts differently (ferx-core #899).CMT=0is NONMEM’s default dose compartment and resolves to compartment 1. The ODE engine previously did four different things with it depending on which internal driver a subject took — including dropping the dose silently — so the same dataset could produce different answers, and a fit could differentiate a different dosing history than it predicted. Every site now resolves it to compartment 1, and compartment-indexed dose attributes (F1,ALAG1) are read correctly. If you have ODE datasets written withCMT=0, earlier results were wrong and should be regenerated.method = "agq"has been removed (ferx-core #251). Adaptive Gauss-Hermite quadrature is not a separate estimator — it is the single-point method with more nodes, so the node count is now an argument and the method name selects the Hessian anchor:ferx_fit(..., method = "laplace", settings = list(n_agq = N))is the exact-anchor quadrature (n_agq = 1is Laplace), andmethod = "focei"withn_agq > 1is the new Gauss-Newton-anchored quadrature. The old"agq"/"gauss_hermite"tokens now error with a pointer to the replacement.
Public functions renamed for verb-clarity and naming consistency (part of the API cleanup in #223; naming rule + hard-break policy decided in #224). Old names are removed - no deprecation shims. Update calls as follows:
Added
?ferx_fitnow documents every fit option the engine accepts (99 of 101; the two exceptions are the FREM structural maps thatferx_model_to_frem()writes for you, and the help says so). Previously 32 keys were reachable throughsettingsbut documented nowhere, so the only way to find them was to read the engine source. Newly documented:inner_restarts,inner_optimizer,cov_inner_tol,parameter_scaling,ebe_warm_start,checkpoint/checkpoint_interval_secs,iov_column/iov_occasion,npde_nsim/npde_seed,sir_df/sir_keep_samples,conddistand its three companions,imp_auto/impmap_auto,imp_defensive_alpha,iscale_min/iscale_max,frem_rao_blackwell,impmap_mcetaandimpmap_sobol. Two of these change how an existing documented option behaves and are worth knowing:imp_auto/impmap_autodefault toTRUE, which makesimp_samples/impmap_samplesa starting count that ramps up rather than a fixed one; and the applicability headings were wrong —method = "laplace"accepts the whole outer-optimizer and iteration-cap block, and the inner-loop keys apply to"imp","impmap"and"bayes"too.Stiff and high-order ODE solvers via
settings = list(ode_method = ...)—"rk45"(default),"vern7","rosenbrock23","rodas4"and"rodas5p". These cover two independent problems that want opposite fixes: a stability-limited (stiff) model — fast reversible binding / TMDD, Michaelis-Menten withKMfar below observed concentrations, long transit chains — takes tiny steps whateverode_reltolasks for, and wants one of the linearly implicit Rosenbrock methods; an accuracy-limited model accepts nearly every step and only slows down asode_reltoltightens, where a stiff method buys nothing andvern7’s higher order is the lever (~2.3× at1e-9on ferx-core’s transit benchmark, but ~1.4× slower at default tolerances). Every method is a full peer — analytic sensitivities, time-to-event and categorical endpoints, simulation and adaptive dosing work with all of them. Also settable in the model file’s[fit_options]block. Delivered via the ferx-core pin bump (ferx-core #952 / #387).Exact analytic covariance R-matrix, on by default — the covariance step now assembles the observed information from third-order sensitivities of the closed-form prediction rather than differencing the objective, for models in scope (plain analytical Gaussian; no IOV, LTBS,
[scaling], M3, FREM or non-Gaussian endpoint). Bothmethod = "focei"andmethod = "foce"are served, from two separate assemblies — the non-interaction one is built on the Sheiner–Beal gradient and carries nolog|H~|term — so neither falls back to finite differences (ferx-core #954 pins both end-to-end). This removes theeps/h^2differencing noise and thefd_hessian_steptuning knob, and costs2 * (n_theta + n_eta) + 1sensitivity evaluations per subject instead of roughly2 * n_free^2objective evaluations that each re-solve every inner loop. Out-of-scope models keep the finite-difference stencil unchanged. Standard errors on in-scope models may shift slightly — they are now exact rather than finite-difference approximations; setsettings = list(analytic_cov_hessian = FALSE)to reproduce pre-bump values. Notefd_hessian_stepis inert on in-scope models for the same reason. Delivered via the ferx-core pin bump (ferx-core #953 / #436).Adaptive dosing now accepts a pre-scheduled base regimen (loading / maintenance dose) —
ferx_simulate_adaptive()no longer requires dose-free base subjects. Ordinary dose rows in the data (EVID = 1/4, withAMT, and optionallyRATE,SS,II) are integrated as a standing prescription and the[adaptive_dosing]controller augments them, the real therapeutic-drug-monitoring / model-informed-precision-dosing workflow. System resets (EVID = 3/4) are also honored. Note the returned dose ledger andmetrics$CUM_DOSEcount controller-issued doses only, so a pre-scheduled base dose is excluded from them (it is still reflected in the trajectories,PCT_TIME_IN_WINDOW, and theauc_targetmetric). New bundled exampleferx_example("adaptive_vanco_loading")— a vancomycin loading-dose + maintenance titration — with a runnableinst/examples/ex_adaptive_vanco_loading.R. Delivered via the ferx-core pin bump (#276; ferx-core #702 / #716 / #929 and follow-ups).New bundled examples
ferx_example("ss_absorption")andferx_example("infusion_absorption")— steady-state dosing (SS=1,II) and infusion (RATE>0) into a built-in absorption compartment (first_order(ka)forcing central), the two dosing routes that ferx-core #719 (gaps 1 and 2) added for the pointwise density absorption kernels. Both were previously rejected at parse time. Each ships a runnableinst/examples/ex_*.Rand is anchored to NONMEM 7.6.0 (ADVAN2 TRANS2): the datasetDVis the NONMEM population prediction andferx_predict()reproduces it to < 1e-4. Steady-state equilibrates the periodic dosing through the absorption kernel; the infusion becomes the zero-order source feeding the kernel.New bundled example
ferx_example("binary_logistic")— a fixed-effects[binary_model](logistic) endpoint: the 0/1 outcome on CMT 3 is Bernoulli withlogit P(DV = 1) = TH0 + THX * X + THT * TIME, the exact analogue of base-Rglm(DV ~ X + TIME, family = binomial). Ships with a runnableinst/examples/ex_binary_logistic.Rthat fits it and showsferx_simulate()returning 0/1DV_SIMon the binary CMT (#271). Delivered via the ferx-core pin bump (#900).Per-route absorption lag in model files — every built-in input-rate function now takes an optional
lag=argument (first_order(ka=KA, lag=L),zero_order(dur=DUR, lag=L),transit,igd,weibull), giving each parallel / mixed pathway its own onset delay on top of any compartment lagtime — the classic immediate-release + delayed-release picture that a single per-dose lagtime cannot express (ferx-core #856). New bundled exampleferx_example("per_route_lag_absorption")with a runnableinst/examples/ex_per_route_lag_absorption.R. Delivered via the ferx-core pin bump.ferx_xpose()now populates the estimation-iteration trace, soxpose::prm_vs_iteration()(parameter value vs iteration) andxpose::grd_vs_iteration()(gradient vs iteration) work on the returned object (#168). When the fit was run withoptimizer_trace = TRUE, the per-parameter value and gradient trajectories are written into the xpose$filesslot as synthetic NONMEM.ext/.grdtables. A newiterationsargument (defaultTRUE) gates this; the.grdtable is only emitted for gradient-based methods, and when no trace is present the slot is left empty (the iteration plots then raise xpose’s usual “no files” message while the goodness-of-fit / covariate plots are unaffected). Only the"xpose"backend is affected. Builds on the ferx-core optimizer trace now carrying per-parameter estimates and gradients per iteration (ferx-core #640).Analytic inverse-Gaussian (IG) absorption is now available in model files -
pk one_cpt_ig(cl, v, mat, cv2)andpk two_cpt_ig(cl, v1, q, v2, mat, cv2)structural models: Freijer & Post inverse-Gaussian absorption fed straight into a one- or two-compartment disposition as an analytic closed form (ferx-core #790), with exact FOCE/FOCEI sensitivities that do not depend on any ODE-solver tolerance and a uniform pk-line interface matching the analytic transit models. It is the closed-form counterpart to the ODEigd()input rate (ferx_example("igd_inverse_gaussian"));fandlagtimeare supported. Outside the closed form’s convergence domain a plain model transparently reroutes to its ODEigd()twin (a model that also mapsf/lagtimehas no twin and is rejected, rather than rerouted). New bundled examplesferx_example("one_cpt_ig")andferx_example("two_cpt_ig")with runnableinst/examples/ex_one_cpt_ig.R/ex_two_cpt_ig.R.Bundled analytic two-compartment transit example - a new
ferx_example("two_cpt_transit")for thepk two_cpt_transit(cl, v1, q, v2, n, mtt)closed form (ferx-core #634): Savic transit-compartment absorption superposed bi-exponentially onto a two-compartment disposition, the 2-cpt analytic counterpart toone_cpt_transitand the closed-form counterpart to the ODEtransit_2cpt. Paired with a model-simulated 2-cpt transit-truth dataset (a genuine parameter-recovery example, unlikeone_cpt_transit’s shared anchor) and a runnableinst/examples/ex_two_cpt_transit.R(closes #251).Inter-occasion variability (IOV) now composes with the analytic absorption closed forms -
pk one_cpt_transit/two_cpt_transit/one_cpt_ig/two_cpt_igpreviously rejected akapparandom effect at parse time. A subject carrying IOV is now transparently rerouted, per subject, to the model’s exacttransit()/igd()ODE twin, which integrates the cross-occasion dose carryover the closed-form superposition cannot express - no switch to a hand-written ODE model is needed, just the samepk ...line plus akapparandom effect andiov_column(via ferx-core #719). New bundled exampleferx_example("one_cpt_transit_iov")- analyticone_cpt_transitwith IOV on CL, paired with an 8-subject subset of the ferx-core transit+IOV NONMEM anchor dataset (simulated from the model, so the fit recovers the data-generating parameters) and a runnableinst/examples/ex_one_cpt_transit_iov.R. Steady-state dosing and infusions under IOV on the analytic path remain unsupported (ferx-core #719). Requires the bumped ferx-core.ferx_simulate()now surfaces per-subject simulation diagnostics from ferx-core (#762 / #763): a degenerate or pathological hazard that would otherwise censor a subject with no event is raised as an R warning and attached to the returned data frame as asimulation_warningsattribute (a character vector, empty for a clean run). Requires the bumped ferx-core (simulate_with_options_diag).New
ferx_covariance(fit)runs the finite-difference-Hessian covariance step against an existing fit without re-estimating (#738), the covariance-step analogue offerx_sir(). Add standard errors to a fit produced withcovariance = FALSE, or re-run the step with a differentcovariance_method(e.g. the"rsr"sandwich), including on a fit loaded from a.fitrxbundle. It re-reads the model/data from the fit’s recorded paths with SHA-256 integrity checks (refusing stale inputs) and refreshescov_matrix,cor_matrix,se_theta/se_omega/se_sigma/se_kappa,covariance_status,eigenvalues, andcondition_number. The numerics closely matchferx_fit()’s inline covariance step (the same engine step; the standalone re-reads the data and cold-starts the inner EBE loop, so agreement is close but not bit-exact); a step that runs but fails (non-PD / unusable Hessian) is non-fatal, reportingcovariance_status = "failed"with a diagnostic warning.Model files may now declare a
[data]block (path = ..., resolved relative to the model file’s directory). Whendatais omitted,ferx_fit(),ferx_model(),ferx_simulate(),ferx_predict(),ferx_predict_survival(),ferx_simulate_adaptive(),ferx_check_init(), andferx_inits_from_nca()fall back to the declared dataset; an explicitdataargument still overrides it (#254).
Fixed
ferx_example("warfarin_scaled")could not be fitted at all. Its model file carriedgradient = adin[fit_options]; the Enzyme automatic-differentiation path was retired in ferx-core in favour of the analyticDual2sensitivities, so the engine now rejects that token outright (E_AD_RETIRED) and the bundledex_warfarin_scaled.Rfailed immediately. Nowgradient = auto.fit$gradient_usednever reported the analytic gradient. The internal label map still translated the retired"Enzyme AD"string and had no case for the engine’s actual"analytic (Dual2)", so the long string fell through unmapped — meaningfit$gradient_used == "ad"was permanentlyFALSEandprint()/summary()rendered the raw engine string. It now reports"analytic".Documentation corrections found by an audit against the engine. The
[scaling]help told users that expression (obs_scale = V) and Form C (y = <expr>) readouts force finite-difference gradients and to setgradient = fd. Both are differentiated exactly under the defaultgradient = auto(ferx-core #486), so that advice lost the analytic gradient and switched the outer optimizer from L-BFGS to BOBYQA; only the per-CMT variants fall back, and they do so silently.bloq_method = "drop"was described as discarding BLOQ rows when it keeps them, fitting each at its limit value. Corrected defaults:inner_tol(1e-4→1e-5),n_mh_steps(10→20),impmap_proposal_df(documented as"normal", actually Student-t4), andthreads(documented as one worker per logical CPU, actually cores − 1 capped at 8). Also:max_unconverged_fracrejects an outer step rather than relaxing the converged flag;covariance_method = "s"/"rsr"work under FOCE, not FOCEI only;optimizer = "bfgs"is a deprecated alias fornlopt_lbfgs, not a distinct algorithm;gradient = "ad"now errors rather than being tolerated; andmethod = "laplace"corresponds to NONMEMLAPLACIAN INTER(plainLAPLACIANdiffers by ~9 OFV units).ODE-form models fit with
method = "foce"now match their analytical closed-form equivalent’s marginal objective (via ferx-core #378). When a subject’s per-subject (inner EBE) objective was multimodal, the analytical and ODE forms could condition on different modes, so their FOCE marginal OFV diverged - by up to ~18 units on some models/platforms (most visibly the 3-compartment IVthree_cpt_ivexample on Linux). ferx-core now keeps the better inner estimate on the analytical path, matching the ODE path, so the two forms agree to solver round-off. Delivered via the ferx-core pin bump.Simulated binary / categorical outcomes are no longer
NA(#271). With a[binary_model]endpoint (ferx-core #900),ferx_simulate()mapped every simulated row throughcontinuous_value(), whose categorical arm returnsNaN, so each binary draw came back asDV_SIM = NAand was indistinguishable from a PK row that failed to predict. The simulate frame now folds a categorical draw intoDV_SIMas its numeric 0/1 outcome (matching how the input CSV codes DV); combined with the existingCMTcolumn, simulated binary outcomes are now usable from R. (TTEEventrows are unchanged:DV_SIM = NA, event time inTIME,OBSERVEDflag set.)ferx_fit()on a model with no random effects (n_eta = 0) - e.g. a fixed-effects[binary_model]logistic regression - no longer errors with “missing value where TRUE/FALSE needed” (#271). An eta-less fit returns an empty omega; the R post-processing left it as a barenumeric(0)instead of a 0x0 matrix, sonrow(omega)wasNULLand poisoned the eta-metadata guards withNA.omegais now always a matrix, son_eta = 0models fit cleanly.ferx_save_fit()/ferx_load_fit()round-trip on extension-less paths (#268). Saving to a path with no file extension (e.g.ferx_save_fit(fit, "results/run1_base_diag")) previously landed atresults/run1_base_diag.zip- because Info-ZIP appends.zipto a suffix-less archive name - so the mirroringferx_load_fit("results/run1_base_diag")failed with “File does not exist”.ferx_save_fit()now appends the conventional.fitrxextension when the output path has none, andferx_load_fit()falls back to<path>.fitrx, so the bare-path round-trip works. Explicit extensions are still honoured as given.Closed-form transit / inverse-Gaussian absorption under IOV, time-varying covariates, or a
TIMEswitch now honors a call-time ODE tolerance and converges its per-subject estimates correctly (via ferx-core #814, a #719 follow-up). These models serve such subjects on an internally generated ODE “twin”; before this aferx_fit(settings = list(ode_reltol = ...))(orode_abstol/ode_max_steps) override was silently dropped on the twin path, and an estimate that fell back to the finite-difference inner gradient could stop short of convergence. Theone_cpt_transit_iovexample above is the main beneficiary. Requires the bumped ferx-core.
ferx 0.2.0
Breaking changes
ferx_npde()->ferx_calc_npde()ferx_selection()->ferx_apply_selection()ferx_to_frem()->ferx_model_to_frem()(moves into theferx_model_*family)ferx_warnings()->ferx_get_warnings()ferx_columns()->ferx_get_columns()ferx_plot_trace(fit)->plot(fit)(#229). New S3 methodsplot.ferx_fit()andplot.ferx_job()replace it;plot.ferx_job()plots the trace accumulated so far by an in-progressferx_fit_async()job, not just a completed fit. FOCE/FOCEI traces now show the running-minimum OFV by default (monotonic = TRUE), since the raw per-evaluation trace includes rejected line-search trial steps that can transiently increase OFV; passmonotonic = FALSEfor the raw trace.
ferx_selection_excluded() is removed. To retrieve excluded records, pass excluded = TRUE to ferx_apply_selection(), which now also accepts a ferx_data or ferx_fit object as its data argument:
# before
sel <- ferx_selection(data, ignore = "DV < 1")
excl <- ferx_selection_excluded(sel)
excl <- ferx_selection_excluded(fit)
# after
excl <- ferx_apply_selection(data, ignore = "DV < 1", excluded = TRUE)
excl <- ferx_apply_selection(fit, excluded = TRUE)ferx_cor_matrix(), ferx_estimates(), and ferx_eta_cov() are removed and replaced with fields computed automatically at the end of ferx_fit() (and recomputed by ferx_load_fit()), part of the fit-accessor cleanup in #226:
ferx_cor_matrix(fit)->fit$cor_matrixferx_estimates(fit)->fit$estimatesferx_eta_cov(fit, data)->fit$eta_cov(no longer takes adataargument; it is computed from the dataset used to fit the model)
The four section-editing functions collapse into one get/set pair, part of the API cleanup in #223 (#233; decision recorded on #227). Both accept either a ferx_model object or a plain path:
ferx_model_section(),ferx_get_section()->ferx_model_get_section()(returns the section’s lines; always a data-return, not a pipe passthrough)ferx_set_section()->ferx_model_set_section()(unchanged behaviour: returnsxfor piping, with copy-on-write for bundled package models)
The ferx_get_section() mid-pipe peek (printing a section and passing the ferx_model object through unchanged) is gone - there is no replacement that both prints and continues the pipe. Call ferx_model_get_section() on its own line before the pipe, or use ferx_model_show() to peek at the whole file:
# before
fit <- ferx_model(ex$data, ex$model) |>
ferx_get_section("parameters") |>
ferx_fit()
# after
ferx_model_get_section(ex$model, "parameters")
fit <- ferx_model(ex$data, ex$model) |>
ferx_fit()# before
ferx_cor_matrix(fit)
ferx_estimates(fit)
ferx_eta_cov(fit, read.csv(ex$data))
# after
fit$cor_matrix
fit$estimates
fit$eta_covAdded
New
ferx_conddist(fit)exposes the SAEM conditional-distribution results (settings = list(conddist = TRUE)) to R (#244): per-subject/per-eta conditional mean, SD, and mode (fit$cond_dist), with distribution-based eta-shrinkage as an attribute. Previouslycond_distwas computed by ferx-core but never reached R, for either in-process fits or.fitrxbundles; it now survivesferx_save_fit()/ferx_load_fit()too.ferx_fit(..., optimizer_trace = TRUE)now stores the per-iteration trace on the fit object itself asfit$trace(a data frame), not just its temp file path (fit$trace_path) (#228).fit$tracesurvivesferx_save_fit()/ferx_load_fit(), andferx_trace(),ferx_runlog(),ferx_runlog_iters()all read it directly when present instead of re-reading a temp file that may have since been deleted.fit$impmap_traceis now only ever populated whenimpmap_trace = TRUEwas actually requested (viasettings =or[fit_options]), guarding against it leaking from an intermediate stage of a method chain.ferx_jobhandles (fromferx_fit_async()) gain a computedtrace_pathfield alongside the existingsidecar_path.ferx_stop()terminates a background fit started byferx_fit_async()without waiting for it to finish (#235). Previously the only way to stop a running job was to send a kill signal manually.ferx_model_to_frem()gains afitargument (#239). Pass aferx_fitresult from fitting the base model and its theta/omega estimates seed the generated FREM model’s PK theta inits and PK-PK omega block, so a subsequent fit of the FREM model warm-starts from converged parameters instead of the base model’s declared inits. Optional;NULL(default) is unchanged behaviour.ferx_model_inspect()now reports covariate-selected residual error models ascovariate-selected (...)inmodel_structure$residual, matching the new[error_model]if/elseselector in ferx-core (ferx-core #658).ferx_model_new()is removed (#231). Scaffolding a new model from a template is now a mode of theferx_model()constructor, selected by passingtemplate =(orprint = TRUEto preview a skeleton without writing a file). Unlike the old function, which returned the file path, scaffold mode returns aferx_modelobject, so it pipes straight intoferx_fit(). The output path moves from the first positional argument to the namedpath =argument:
# before
ferx_model_new("m.ferx", template = "1cpt_oral", edit = FALSE)
ferx_model_new(print = TRUE)
# after
ferx_model(template = "1cpt_oral", path = "m.ferx", edit = FALSE)
ferx_model(print = TRUE)
# scaffold + fit in one pipe
ferx_model(template = "1cpt_oral", path = "m.ferx", edit = FALSE) |>
ferx_fit(data)Fixed
- Datasets that reuse a subject ID in a non-contiguous block (e.g. a second cohort reusing IDs 12/13/14) are now handled correctly by the per-subject joins in
ferx_xpose(),ferx_save_fit(),fit$eta_cov, andferx_cov_screen(). ferx-core (like NONMEM) processes records sequentially, so each block is a distinct subject even when two share a textual ID; these functions previously keyed their joins on the raw ID, which silently gave the second block the first subject’s ETAs/parameters (xpose), dropped it and wrote anebes.csvthat disagreed withpredictions.csvso the loader rejected the bundle (save_fit), or double-weighted the shared covariate row (eta_cov / cov_screen). Joins now key on subject order instead (#252, alongside ferx-core #743).
ferx 0.1.6
Added
Analytic Savic transit absorption is now available in model files — a
pk one_cpt_transit(cl, v, n, mtt)structural model: Savic transit-compartment absorption fed straight into a one-compartment disposition as a fast analytical closed form (exponential tilting; no ODE solve), with exact FOCE/FOCEI sensitivities and a continuous, estimable number of transit compartmentsN(via ferx-core #611 / #386). It is the closed-form counterpart to the ODEtransit(n, mtt)input rate (ferx_example("transit_savic")) and is much faster; withN = 0it reduces to first-order oral absorption. New bundled exampleferx_example("one_cpt_transit")with a runnableinst/examples/ex_one_cpt_transit.R.State-reactive (adaptive / feedback) dosing simulation —
ferx_simulate_adaptive()runs a forward simulation whose dosing regimen is decided at run time by the model file’s[adaptive_dosing]block: a declarative first-matching-rule controller that titrates the next dose from the simulated (optionally assay-noised) trough at each decision time (via ferx-core #585, epic #391). The base subjects are dose-free — the controller supplies every dose. Returns the concentration trajectories, the realized dose ledger, the per-decision log (including holds), and per-subject outcome metrics — cumulative dose, realized dose-change counts, holds, discontinuation, the observed-signal summary, and the fraction of monitored values inside the model’starget_windowwhen one is declared (metrics via ferx-core #605); the frozen-schedule replay verifier runs on every replicate. New bundled exampleferx_example("adaptive_tdm")(a vancomycin-style TDM trough titration) with a runnableinst/examples/ex_adaptive_tdm.R.Joint PK-TTE (drug-driven hazard) is now available in model files — an
[event_model] hazard = <expr>that references the ODE PK state (e.g.H0 * exp(BETA * (central / V))) is accumulated as a cumulative-hazard ODE compartment and estimated jointly with the PK by FOCEI/SAEM, with shared random effects (via ferx-core #564). Mutually exclusive with the analyticfamilyhazard; requires an ODE model. Validated three-way (ferx vs NONMEM vs nlmixr2). New bundled exampleferx_example("pktte_joint")with a runnableinst/examples/ex_pktte_joint.R(ferx_fit()+ferx_predict_survival()).Joint PK-TTE event-time simulation —
ferx_simulate()gains ahorizonargument and now samples drug-driven (ODE-accumulated) time-to-event endpoints (via ferx-core #564, Slice 2.2). With a finitehorizon, a joint PK-TTE model yields, per subject, its continuous PK rows plus a TTE row on the event CMT carrying the sampled event/censorTIMEand anOBSERVEDflag (1 = event before the horizon, 0 = right-censored at it;NAfor continuous rows). The simulation output gainsCMTandOBSERVEDcolumns.Zero-order absorption is now available in model files — the built-in
zero_order(dur)[odes]input rate (a constant-rate / modeled-duration input, NONMEMRATE=-2/D1), via the ferx-core update (ferx-core #504). Two new bundled examples:ferx_example("zero_order_absorption")(constant-rate input into central) andferx_example("sequential_absorption")(zero-order fill of a depot, then first-orderkato central).Biphasic / parallel absorption in model files — an
[odes]input-rate term can now be scaled by a declared pathway fraction (FR*igd(...)) and more than one term can feed a compartment, so the Freijer & Post biphasic inverse-Gaussian model isd/dt(central) = FR1*igd(...) + FR2*igd(...)(via ferx-core #388). New bundled exampleferx_example("biphasic_igd_absorption"). Validated against a NONMEM$DESbiphasic run (ferx FOCEI objective vs#OBJVto ~1e-5).Parallel / mixed dual-pathway absorption in model files — the new built-in
first_order(ka)[odes]input rate (classic first-order / Bateman absorption, exposed as a composable input rate) plus a pathway fraction onzero_order(...)let two absorption pathways be split by a dose fraction:parallel(FR1*first_order(ka=KA1) + FR2*first_order(ka=KA2)) andmixed(FZO1*first_order(ka=KA) + FZO*zero_order(dur=DUR)) (via ferx-core #505). Two new bundled examples:ferx_example("parallel_absorption")andferx_example("mixed_absorption"). Validated against NONMEM$DESruns (ferx FOCEI objective vs#OBJVto ~1e-5 parallel / ~1e-4 mixed).ferx_predict_survival()— survival-function predictions (S(t),H(t),h(t), plus median and mean survival) on a user-supplied time grid for[event_model](time-to-event) endpoints, for every subject and TTE CMT. Mirrorsferx_predict(); optionally uses afit’s estimatedtheta. For competing risks (multiple TTE CMTs) it also returns the cause-specific cumulative incidencecifand all-cause survivalsurvival_all, withsum(cif) + survival_all = 1(ferx-core #501).Time-to-event support is now compiled into the package. The Rust backend enables ferx-core’s
survivalfeature by default, so[event_model]blocks and the TTE datareader routing are active in shipped builds (previously the feature was off, making that routing a no-op).Bundled examples for time-to-event and Savic transit absorption. New
ferx_example()models, each with a runnableinst/examples/ex_*.Rscript:tte_exponential,tte_weibull,tte_gompertz, andtte_competing_risks(standalone[event_model]TTE, paired withferx_predict_survival()), plustransit_savic(Savic transit-compartment absorption via the built-intransit(n, mtt)input rate).New
outer_xtol/outer_ftolfit settings — expose the derivative-freebobyqaouter optimizer’s step / objective stop tolerances (NLoptxtol_rel/ftol_rel), settable viaferx_fit(settings = list(...))or the model file’s[fit_options].outer_ftoldefaults to an automatic per-model value (tighter for time-to-event, where the objective is exact). See?ferx_fit(ferx-core #469).
Breaking changes
- Importance-sampling result/settings names use the
imp_*prefix.fit$is_seedis nowfit$imp_seed, and IMP settings now useimp_samples,imp_proposal_df,imp_seed, andimp_low_ess_threshold. The short-livedis_*names are no longer accepted, matching ferx-core (FeRx-NLME/ferx-core#422).
Fixed
Time-to-event frailty variance now matches NONMEM / nlmixr2. A weakly-identified
omega^2on a nonlinear hazard parameter (e.g. a Weibull shape frailty) previously read high because the derivative-free outer optimizer stopped short on the near-flat objective ridge. It now converges onto the NONMEM LAPLACIAN / nlmixr2 FOCEI consensus (the reference Weibull dataset movesomega^20.204 → 0.176). Automatic for[event_model]fits — no model change needed (ferx-core #469).ferx_fit()no longer overrides model-file[fit_options]with accepted defaults.covariance,verbose,mu_referencing,sir, andgradientnow default toNULL, meaning “use the model file’s value” (falling back to the engine default when the model file is silent). Previously their non-NULLR defaults (e.g.covariance = TRUE) silently overrode a model file that set the opposite — so a model withcovariance = falsestill ran the covariance step. Pass the argument explicitly to override the model file (FeRx-NLME/ferx-core#558).ferx_fit()no longer overrides the model file’s estimation method.methodnow defaults toNULL, meaning “use the[fit_options] methodfrom the model file” (falling back to FOCEI only when the model file sets none). Previously the R-side defaultmethod = "focei"silently overrode a model file that specified e.g.method = saem. Passmethodexplicitly to override the model file as before (FeRx-NLME/ferx-core#558).ferx_selection()preview recognizes the bareignore = Cshorthand (NONMEMIGNORE=C). The pure-R preview parser previously returned no match for an operator-less clause, so the preview reported zero exclusions while the Rust fit dropped the flagged comment rows. A lone column name now expands toC == C(case preserved on the value to match the raw cell),Inf/-Infnow joinNaNin being treated as label strings rather than numeric values, and an ordered comparison against a non-numeric value (e.g.BW < abc) yields no exclusion instead of a lexical string compare - all matching ferx-core (FeRx-NLME/ferx-core#536).ferx_to_frem()now warns about estimated parameters with no random effect and carries the base model’s scaling over to the FREM model. A non-fixed parameter without anETAis estimated poorly by IMP/IMPMAP (the importance-weighted M-step is biased for weakly-identified fixed effects), soferx_to_frem()now emits a warning at conversion time recommending anETAbe added (ferx mu-references automatically), the parameter be held fixed, or FOCEI be used. The base model’s[scaling]/[odes]blocks (e.g.obs_scale) are also now transferred to the generated FREM model instead of being dropped — droppingobs_scalerescaled every prediction and collapsed a PK typical value during FREM fits. Requires ferx-core with these fixes (FeRx-NLME/ferx-core#406, #407).
Changed
optimizernow defaults to"auto"(FeRx-NLME/ferx-core#490). The new"auto"choice picks the population optimizer per model:"nlopt_lbfgs"when the exact analytic FOCE/FOCEI gradient is available, and"bobyqa"when only finite differences are. Passsettings = list(optimizer = "auto")(or omit it) to get the automatic choice; the fit reports the resolved optimizer as"auto (<resolved>)". Setoptimizer = "bobyqa"for the previous fixed default.No special Rust toolchain is needed to build. ferx-core now uses hand-rolled analytic sensitivities (FeRx-NLME/ferx-core#381), so the package builds with the stable Rust toolchain. The former custom-toolchain build switches and preflight check are gone. The legacy build-mode probe is retained but now always returns
FALSE. Gradients are unchanged (exact analytic sensitivities). Also bumps the bundlednalgebrato 0.35 to match ferx-core.method = "imp"is now an estimator by default (NONMEMMETHOD=IMP): it updates the population parameters by importance-sampling Monte-Carlo EM instead of only evaluating the marginal-2 log Lat fixed parameters. Breaking: calls that usedmethod = "imp"(orc("focei", "imp")) purely to score a fit now re-estimate — passsettings = list(imp_eval_only = TRUE)(NONMEMEONLY=1) to recover the old evaluation-only behaviour. Newsettings:imp_iterations,imp_averaging,imp_eval_only;imp_proposal_dfnow also accepts"normal"/"mvn". The estimating"imp"may lead or sit mid-chain; the evaluation-only"imp"must still be terminal. Plain"imp"is fragile on rich data (warm-start withc("focei", "imp"), or use"impmap"). Requires ferx-core with theMETHOD=IMPestimator (FeRx-NLME/ferx-core#402). (#181)
Performance
ferx_fit()no longer pays a ~100 ms per-call latency floor. The R-interrupt poll loop in the Rust binding slept a fixed 100 ms between checks, so any fit that finished in between (single-subject MAP/posthoc, small datasets, quick refits) still took ~0.1 s of wall time regardless of the engine’s actual runtime. The worker now signals completion on a channel, so the call returns the instant the fit finishes;POLL_MSbounds only Ctrl-C latency. A single-subject fixed-parameter MAP fit drops from ~0.118 s to ~0.005–0.009 s (~13–24×); estimates and interrupt behaviour are unchanged. (#178)
New features
Laplace estimator (
method = "laplace"): alias"laplacian". The Laplace approximation with the exact Hessian — NONMEM$EST METHOD=1 LAPLACIAN, which it reproduces to six significant figures. This is not the same estimator as"focei", which builds its Gaussian from the Gauss-Newton Hessian and reports a different OFV. Internally it is"agq"with the node count pinned to 1 (a bit-identical OFV), making it the cheapest member of that family — on warfarin it converges faster than FOCEI. It supports IOV at any occasion count. (ferx-core #251)AGQ and
laplacenow support inter-occasion variability ([iov]): the integral runs over the stacked(eta, kappa_1..kappa_K)vector. AGQ’s grid grows with the occasion count (n_agq^(n_eta + K*n_kappa)) and is capped;laplaceis a single node regardless, so it is always tractable under IOV. (ferx-core #251)Adaptive Gaussian quadrature (
method = "agq"):ferx_fit(..., method = "agq")(aliases"aghq","gauss_hermite") selects the new AGQ estimator, withsettings = list(n_agq = 3)setting the Gauss-Hermite nodes per random effect. AGQ generalises Laplace — instead of a single Gaussian at each subject’s empirical-Bayes mode it evaluates the exact conditional likelihood on a Gauss-Hermite grid around that mode, son_agq = 1reproduces Laplace identically and more nodes refine the marginal. Because it makes no Gaussian-residual assumption it covers non-Gaussian endpoints (time-to-event, categorical) that FOCE/FOCEI structurally cannot, and unlike SAEM/IMP its objective is deterministic (the OFV is bit-identical run to run). It carries an exact analytic outer gradient, so a converged warfarin fit is faster than FOCEI. Validated against NONMEM$EST METHOD=1 LAPLACIAN, whichn_agq = 1reproduces to six significant figures. Cost isn_agq^n_etaper subject per iteration, so it suits models with few random effects. IOV is supported (see above). (ferx-core #251)Weibull absorption —
weibull(td, beta): a new built-in absorption input rate for[odes]models, alongsidetransit(...)andigd(...). It adds a Weibull absorption-time distribution — scaleTd, shapebeta— fed straight into the central compartment, modelling the entire absorption delay in one term (no first-orderka). The shape selects the profile:beta > 1a delayed interior peak,beta = 1first-order absorption (ka = 1/Td),beta < 1fast early uptake. The dose feeds the density over time (∫ R_in dt = F·Dose), not as a bolus, exactly liketransit(...)/igd(...), and drives exact analytic FOCE/FOCEI/Bayes gradients. New exampleweibull_absorption. Anchored against a NONMEM$DESWeibull run (ferx FOCEI matches NONMEM#OBJVto ~1e-6). Requires ferx-core with FeRx-NLME/ferx-core#497.IIV on residual error (
iiv_on_ruv): a.ferxmodel can now place a random effect on the residual error, matching NONMEMY = IPRED + EPS*EXP(ETA). Declare anomegaand reference it from[error_model]withiiv_on_ruv = NAME; each subject then gets a log-normally scaled residual SD. Supported under FOCEI, IMP, IMPMAP, and SAEM. Validated against NONMEM 7.5.1 (ΔOFV 0.017). Requires ferx-core with this feature (FeRx-NLME/ferx-core#409).M3 LOQ censoring supports upper limits: datasets may now use
CENS = -1to mark observations censored above an upper limit of quantification, withDVcarrying the ULOQ value. ExistingCENS = 1lower-limit handling is unchanged. Requires ferx-core with FeRx-NLME/ferx-core#416.Inverse-Gaussian (Freijer & Post) absorption —
igd(mat, cv2): a new built-in absorption input rate for[odes]models, alongsidetransit(...). It adds an inverse-Gaussian absorption-time distribution — mean absorption timeMAT, relative dispersionCV2(= Var/mean²) — fed straight into the central compartment, modelling the entire absorption delay in one term (no first-orderka). The dose feeds the density over time (∫ R_in dt = F·Dose), not as a bolus, exactly liketransit(...). New exampleigd_inverse_gaussian. Anchored against a NONMEM$DESinverse-Gaussian run. (Requires ferx-core with FeRx-NLME/ferx-core#347; the biphasic Freijer sum-of-two is a planned follow-up, FeRx-NLME/ferx-core#388.)FREM covariate analysis (
ferx_to_frem()): transforms a base model and dataset into a Full Random Effects Model (FREM) that treats covariates as additional dependent variables. The extended omega block captures covariate-parameter relationships in a single fit, avoiding stepwise search. Covariates (and their continuous/categorical kind) are taken from the model’s[covariates]block; thecovariatesargument is an optional subset filter to FREM only some of them. Returns aferx_modelreferencing the generated model and data files, so it composes directly:ferx_fit(ferx_to_frem(...)). (#194)IMPMAP estimator:
ferx_fit(..., method = "impmap")(alias"importance_sampling_map") runs the NONMEMMETHOD=IMPMAPMonte-Carlo EM estimator — importance sampling assisted by mode-a-posteriori re-centering. Runs standalone or as a chain stage (c("focei", "impmap")). Tuned viasettingskeysimpmap_iterations,impmap_samples,impmap_proposal_df("normal"for the MVN proposal, or a Student-t DoF),impmap_averaging,impmap_seed,impmap_low_ess_threshold. Requires a mu-referenced parameterization; IOV is not yet supported. Needs a ferx-core that provides theimpmapmethod (separateCargo.lockbump). (ferx-core #270)Modeled infusion duration (
RATE = -2): a NONMEMRATE = -2dose now infusesAMTover a modeled duration — declare an individual parameterD{n}for the dose compartmentnand ferx infuses at rateAMT / D{n}, resolved per iteration and occasion (so it carries covariate and IOV effects), on both the analyticalpk(...)engine andode(...)models. Composes withF{n}andALAG{n}, steady state, multi-dose, and system resets. ARATE = -2dose with no matchingD{n}parameter is a clear error rather than a silent bolus, and aD{n}that is non-positive at the initial estimate is flagged. Handled entirely in the data reader and model parser, so no R-side change is needed. (Requires ferx-core with FeRx-NLME/ferx-core#384.)Modeled infusion rate (
RATE = -1): a NONMEMRATE = -1dose now infusesAMTat a modeled rate — declare an individual parameterR{n}for the dose compartmentnand ferx infuses at rateR{n}(durationAMT / R{n}), resolved per iteration and occasion (so it carries covariate and IOV effects). The mirror of the modeled-durationRATE = -2, supported on both the analyticalpk(...)engine andode(...)models. Composes withF{n}andALAG{n}, steady state, multi-dose, and system resets. ARATE = -1dose with no matchingR{n}parameter is a clear error rather than a silent bolus, and anR{n}that is non-positive at the initial estimate is flagged. Handled entirely in the data reader and model parser, so no R-side change is needed. (Requires ferx-core with FeRx-NLME/ferx-core#418.)ferx_npde(fit, nsim, seed): compute simulation-based NPDE (Normalized Prediction Distribution Errors, decorrelated within subject) and NPD (Normalized Prediction Discrepancies) post-hoc from an existing fit, without re-runningferx_fit(). Useful when a model was fitted without[fit_options] npde_nsim. Returns thefitwithNPDE/NPDcolumns added tofit$sdtab, soferx_xpose()and goodness-of-fit plots pick them up automatically; model/data default to the paths recorded on the fit. (ferx-r #172, requires ferx-core #377)Bayesian estimation (
method = "bayes"): full MCMC posterior sampling (Gibbs-within-HMC, NONMEMMETHOD=BAYESparity). Returns posterior means with 95% credible intervals and convergence diagnostics (split-R-hat, ESS) onfit$bayesinstead of a point estimate;print()shows a posterior-summary table. Tuning viasettings = list(bayes_warmup=, bayes_iters=, bayes_chains=, bayes_thin=, bayes_seed=). Supports BSV and zero-mean inter-occasion variability (per-occasionkappa; the IOV variance posterior appears asOMEGA_IOV(...)). Validated against FOCEI and NONMEMMETHOD=BAYESon warfarin (ferx-core #380).ode_template— generate the disposition ODE:ode_template NAME(...)in[structural_model]writes the standard disposition ODE for a named model (one/two/three_cptiv/oral) for you — the same states, micro-constant RHS, andobs_scalethe analyticalpk NAME(...)uses, but as an explicit ODE you can extend. It takes the same parameters aspk NAME(...)(includingkafor oral routes). Re-declaring ad/dt(X)in[odes]overrides the generated equation for compartmentX(undeclared compartments keep theirs) — the standard way to attach a built-in absorption input such astransit(...). Combining an ODE-only absorption function with an analyticalpk NAME(...)is now a clear error pointing atode_template, never a silent conversion. New exampletwo_cpt_oral_cov_ode_template(verified identical to its analytical and hand-ODE siblings intest-ode-analytical-equivalence.R). (Requires ferx-core with FeRx-NLME/ferx-core#363.)Xpose interoperability:
ferx_xpose(fit)turns a fit into a ready-to-use Xpose object in memory (no NONMEM table files written to disk), so all downstream Xpose goodness-of-fit, covariate, and parameter diagnostics work out-of-the-box. Supports both the modern tidyversexposepackage (backend = "xpose", default) and the classic S4xpose4(backend = "xpose4"). Continuous vs categorical covariates are split using the model’s[covariates]types, overridable via thecontinuous/categoricalarguments.RES/IRESare derived andWRESisNA(ferx does not compute the FO-weighted residual). The estimation-iteration trace is not populated, soxpose::prm_vs_iteration()/grd_vs_iteration()are not supported (pending an engine change); useferx_plot_trace()for OFV over iterations. When the fit carries simulation-basedNPDE/NPDcolumns (from[fit_options] npde_nsim > 0), they are mapped to the Xpose residual role, so residual diagnostics (e.g.xpose::res_vs_idv(xpdb, res = "NPDE")) work on them out-of-the-box. (ferx-r #165)Configurable ODE solver tolerance: ODE models accept
ode_reltol(default1e-4),ode_abstol(default1e-6), andode_max_steps(default10000) in the model file’s[fit_options]block or viaferx_fit(settings = list(ode_reltol = ...)). Defaults are unchanged, so existing fits are unaffected. PRED reproduces the analytical closed form to about1e-4, but the FOCE objective amplifies solver error, so the OFV of an ODE-form model could differ from its analytical equivalent by several units; a tighterode_reltollets the two agree. The shipped*_odeexamples now setode_reltol = 1e-10, andtest-ode-analytical-equivalence.Rchecks the OFV agrees within a tolerance band in addition to PRED. (Requires ferx-core with FeRx-NLME/ferx-core#334.)Standard PK models in ODE form: every standard analytical model (
one_cpt_iv, one-compartment oral =warfarin,two_cpt_iv,two_cpt_oral_cov,three_cpt_iv,three_cpt_oral) now ships an ODE-form example alongside its analytical counterpart (*_ode, plus new analyticalone_cpt_iv/three_cpt_oralexamples and datasets). The ODE forms use an amount-based convention (states are amounts; observed concentration via[scaling] obs_scale = V/V1), with bioavailabilityFand lag time applied by the engine at the dose rather than baked into the[odes]RHS. A new test (test-ode-analytical-equivalence.R) asserts each shipped pair gives identical predictions; the exhaustive cross-check across all dosing modes (bolus, infusion, multi-dose, steady state, lag, F) lives in ferx-core (tests/analytical_ode_equivalence.rs). Also fixesbioavailability_ode, which double-countedF(it was both declared as an individual parameter – applied at the dose by the engine – and baked into the absorption flux). (#127)Propensity-score-matched simulation:
ferx_simulate(..., match = ...)reassigns each replicate’s drawn etas to subjects by Mahalanobis matching (under the model omega) against the subjects’ fitted (posthoc) etas, so a subject’s observed dosing/sampling design is paired with a similar drawn eta. This corrects VPC bias from treatment adaptation in real-world data (e.g. longer dosing intervals for high-clearance patients).matchacceptsFALSE/"none"(off),"optimal"(orTRUE; global linear-assignment minimum, best on average and recommended),"nearest"(greedy nearest-neighbour), or"rank"(pair by Mahalanobis-norm rank). Requires observed data; the posthoc etas use the fitted parameters when afitis supplied. Needs a ferx-core that providessimulate_with_optionswith thematch_methodoption (separateCargo.lockbump). (ferx-core #288, #396)Standalone importance sampling:
ferx_fit(..., method = "imp")now runs without a preceding estimator, scoring the model’s initial parameters (fit$importance_samplingis populated,fit$method_chainis"IMP"). The R-side guard that rejected a lone"imp"has been removed; the at-most-once and must-be-terminal checks remain. Needs a ferx-core that allows standalone IMP (separateCargo.lockbump). (ferx-core #269)Covariance estimator & non-PD fallback options, forwarded via
settings:covariance_method("r"inverse-Hessian /"s"score cross-product /"rsr"Huber-White sandwich standard errors) andcovariance_fallback("sir"runs SIR with an absolute-eigenvalue-rectified proposal when the finite-difference Hessian is not positive definite).fit$covariance_statuscan now be"sir_fallback", which is documented and labelled. Needs a ferx-core that provides these options (separateCargo.lockbump). (ferx-core #245, #248)ferx_model_show()now syntax-highlights.ferxfiles in colour-capable consoles: section headers ([parameters], …) in bold yellow, declaration keywords (theta,omega,sigma,kappa, …) in cyan, and comments dimmed – via the optionalclipackage. Non-colour contexts (files, pipes,NO_COLOR, or nocliinstalled) print the raw text unchanged. (#4)Covariate screen (
ferx_cov_screen()): a quick, informal screen that correlates each declared covariate (fromfit$covtab) with every parameter that has IIV – against both the subject’s individual parameter estimate and its ETA. Covariates are aggregated to one value per subject first (median for continuous, most-frequent level for categorical), and associations are reported as a signed Pearson correlation (continuous) or a correlation ratio (categorical), keeping only pairs above a threshold (default|r| >= 0.2). Intended to flag what is worth a formal covariate search, not as a covariate test itself.Data-selection filtering (
[data_selection]block,ferx_fit(ignore=),ferx_selection()): records can now be excluded from the analysis dataset at read time without modifying the CSV – equivalent to NONMEM$DATA IGNORE=/ACCEPT=. Three entry points:[data_selection]block in.ferxmodel files (keysignore,accept,ignore_subjects).ferx_fit(model, data, ignore = "DV < 1.0", accept = ..., ignore_ids = ...)passes conditions directly from R; conditions from both the model file and the R call are merged and deduplicated.ferx_selection(data, ignore = ..., accept = ..., ignore_ids = ...)is a pure-R preview that returns aferx_dataS3 object you can inspect before fitting, or pass directly toferx_fit()as thedataargument.
Exclusion counts are exposed on
fit$exclusions(a list withn_records_total,n_obs_excluded,n_dose_excluded,n_other_excluded,excluded_subject_ids,fired_ignore,fired_accept).print.ferx_fit()shows a DATA SELECTION block when rules fired.ferx_runlog()includes an exclusion count line in the data summary. Exclusions surviveferx_save_fit()/ferx_load_fit()round-trips. The bundledwarfarin_data_selectionexample demonstrates the feature.ferx_selection_excluded(x)is a new generic: called on aferx_dataobject it returns the excluded rows (with a.exclude_reasoncolumn); called on aferx_fitit re-reads the data file and marks records from excluded subjects.ferx_columns(data)prints the column headers of a NONMEM CSV dataset, grouped into required NONMEM columns (ID,TIME,DV,EVID,AMT,CMT), optional NONMEM columns (RATE,MDV,II,SS,CENS,OCC), and covariates / user-defined columns. Accepts a file path, aferx_fitobject (usesfit$data_path), or aferx_example()list. Returns the column name vector invisibly.ferx_runlog(fit)produces a NONMEM-style.lstrun summary: model file content, data summary (subject / observation counts, time range), INITIAL vs FINAL parameter table with SE and %RSE for every theta/omega/sigma, estimation settings (optimizer, max iterations, BLOQ method, NCA warm-start, random seeds, covariate columns present in the data), OFV / AIC / BIC with convergence flag, covariance-step condition number and eigenvalues, ETA/EPS shrinkage, Durbin-Watson autocorrelation, Shapiro-Wilk ETA normality, and final gradient with a convergence threshold check. Passverbose = FALSEto capture the output as a character string.ferx_runlog(fit, show_iterations = TRUE)gains an Iteration history section whenoptimizer_trace = TRUEwas used: per-iteration OFV, delta-OFV, and method-specific convergence metrics (GRAD_NORM / STEP_NORM for FOCE/FOCEI/BFGS; LM_LAMBDA + ACC for Gauss-Newton; COND_NLL + GAMMA + MH_ACCEPT for SAEM). Runs with more than 30 iterations are truncated to the first 10 and last 10. Setshow_iterations = FALSEto suppress the section.ferx_runlog_iters(fit)is a new function that prints the complete untruncated per-iteration table. Accepts aferx_fitobject or a path to a trace CSV.print.ferx_job(handle)now shows a live trace snapshot (last 5 iterations) when the rstudio-backend handle is printed during a running fit.fit$sdtabgains aCMTcolumn for multi-endpoint models (present whenever any observation row hasCMT != 1). Use this column to split GOF plots by endpoint without rejoining the original dataset.fit$sdtabnow carries the actual subject ID from the data (parsed as numeric where possible). Previously the ID column was a 1-based loop index that broke downstream joins when IDs were non-consecutive or non-numeric. Non-numeric IDs now trigger awarning-severity message.ferx_fitobjects from file-based fits now carryfit$model_text(verbatim.ferxsource),fit$theta_init/fit$omega_init/fit$sigma_init(optimizer starting values),fit$obs_time_range,fit$final_gradient,fit$optimizer_label,fit$bloq_method_label,fit$n_starts,fit$inits_from_nca,fit$covariate_names, and several reproducibility seed fields. These fields powerferx_runlog()and are preserved in.fitrxbundles.
Bug fixes
Oral models with a depot-bypassing infusion (
RATE > 0into the central compartment) now return correct concentrations for subjects fit through the event-driven analytical path (those with time-varying covariates, reset records, or IOV); the infusion input was previously dropped, giving ~0 predictions for those subjects while no-covariate subjects were unaffected. Delivered by bumping the ferx-core pin; no wrapper change (ferx-core#351).A
[structural_model]PK parameter that maps to a name not defined in[individual_parameters](e.g.pk one_cpt_oral(cl=CL, ...)with noCL) is now a clear parse error instead of silently fitting a structurally broken model (every prediction floored, 100% shrinkage). An unrecognized PK-parameter key (a typo such asclx=) is likewise rejected, and a numeric-literal value (e.g.ka=1.0) is honored as a constant rather than silently zeroed. Delivered by bumping the ferx-core pin; no wrapper change (ferx-core#261).Datasets without an
EVIDcolumn no longer silently fit a dose-free model. ferx now infers a dose from a nonzeroAMTwhenEVIDis absent (matching NONMEM), so a NONMEM dataset that marks doses only byAMT/MDV=1administers correctly instead of dropping every dose. The reader also warns whenAMT != 0rows are not treated as doses, or when a population parses zero doses despite having observations. Delivered by bumping the ferx-core pin; no wrapper change (ferx-core#262).IOV models: the
sdtabdiagnostic table (fit$sdtab) now reports each observation’s occasion individual parameters –CL,V,KA, any[derived]/[output]column, andTAD– instead of silently usingkappa = 0for every row, so a parameter with inter-occasion variability no longer looks identical across occasions.TADadditionally shifts each dose by its own occasion (and covariate) absorption lag. Delivered by bumping the ferx-core pin; no wrapper change (ferx-core#238).Shapiro-Wilk ETA-normality flags now fold into a single warning that lists every flagged ETA (with its p-value) instead of firing one warning per ETA. Both
fit$warningsand the structuredeta_normalitywarning shown byferx_warnings()are affected (ferx-core#163).ferx_runlog(): theta names now resolve vianames(fit$theta)(whereR/fit.Rstores them) instead offit$theta_names(which isNULLby design after the R post-processing step). Fall-back chain:names(fit$theta)→fit$theta_names→fit$model_structure$theta_names→THETA(i).ferx_runlog():model_text,inits_from_nca, and seed fields (multi_start_seed,saem_seed,sir_seed_used,imp_seed) now guard againstNA_character_/NA_real_values that extendr emits for RustOption<T>::None, preventing spurious “NA” entries in the run log.ferx_runlog(): gradient-tolerance line suppressed for derivative-free optimizers (BOBYQA, GN, SAEM) where a gradient tolerance is not applicable.ferx_rust_fit()(internal):fit$model_textwasNAfor file-based fits because the R binding’s provenance block setmodel_path/model_hashbut did not setmodel_text. Fixed by reading the model file in the same block.
ferx 0.1.5
Documentation
?ferx_fit: thesettingsparameter block is restructured into labelled sections, one per estimation method (Shared, FOCE/FOCEI/GN-hybrid, Trust-region, SAEM, Gauss-Newton, Importance Sampling, SIR, Multi-start). Each key now lists its default value and which methods accept it.
New features
ferx_fit_async(model, data, ...)now returns aferx_jobhandle immediately so the R session stays free. Callferx_collect(handle)to block-wait with live optimizer-trace progress; passverbose = FALSEto suppress the display and just block until the result is ready. In RStudio the fit appears in the Jobs pane; elsewhere acallr::r_bg()background process is used. The returnedferx_fitobject is identical to whatferx_fit()produces. Breaking change from #91:ferx_fit_async()previously blocked and returned the fit directly; it now returns a handle that must be passed toferx_collect().print(fit)has a new compact layout: a prominentSTATUS: CONVERGED/NOT CONVERGEDline with iteration count and wall time immediately after the header; OFV / AIC / BIC on one line; bold section headers with thin rules instead of--- THETA Estimates ---banners; shrinkage as a single line with inline[!]for values > 30%; a diagnostics line consolidating covariance status, condition number, and Durbin-Watson; and a colour-coded warning-count footer pointing atferx_warnings(fit). Programmatic access (fit$theta,ferx_estimates(),summary()) is unchanged.ferx_warnings(fit)pretty-prints fit warnings grouped by severity (critical / warning / info) with per-category remediation guidance.ferx_warnings(fit, as_df = TRUE)returns the underlyingfit$warnings_structureddata frame (columns:severity,category,message,source_method). Durbin-Watson autocorrelation guidance is direction-aware (positive vs negative DW) and suppresses the SDE hint when the model already uses a[diffusion]block.Default outer optimizer for FOCE / FOCEI changed from
slsqptobobyqa. BOBYQA is derivative-free (a quadratic trust-region) and re-evaluates the per-subject EBEs at every trial point, so it avoids the fixed-EBE FD gradient bias that drives SLSQP to local minima hundreds of OFV units above the true optimum on ODE / PD models, sparse data, and Emax-Hill identifiability problems. The default also flows to the FOCEI polish stage ofmethod = "gn_hybrid"and to the polish stage of anymethod = c(..., "focei")chain. Pure SAEM and puregncontinue to ignore the optimizer setting. To restore the previous behaviour, passsettings = list(optimizer = "slsqp"). Requires a ferx-core build that includes the change.Log-transform-both-sides (LTBS) residual error: fit on the log scale with additive error, the equivalent of NONMEM’s
Y = LOG(F) + EPS(1). Write the[error_model]block as either form:log(DV) ~ additive(ADD_LOG) # DV on the natural scale; engine logs it DV ~ log_additive(ADD_LOG) # DV already log-transformed in the dataUnder LTBS the fit output (
IPRED,PRED,CWRES,IWRESinsdtab, andDV_SIMfromferx_simulate()) is on the log scale.ferx_model_inspect()reports the residual type asadditive (log-transformed). Requires a ferx-core build that includes the feature.settings = list(reconverge_gradient_interval = N)controls how often the FOCE/FOCEI population gradient re-solves each subject’s inner EBE loop instead of holding it fixed.0(default) keeps the cheap fixed-EBE gradient;1reconverges every gradient evaluation;Nreconverges everyN-th. The fixed-EBE gradient can stallslsqpabove the derivative-free (bobyqa) optimum on ill-conditioned non-IOV fits; reconverging recovers the full surface at ~5-6x the per-gradient cost. IOV models always reconverge and ignore the setting. Requires a ferx-core build that includes the option.Multi-endpoint (per-CMT) residual error models for simultaneous PK/PD fitting. A single
[error_model]block can now assign a distinct error model to each observed compartment, dispatched by the datasetCMTcolumn:[error_model] CMT=2: DV ~ proportional(PROP_ERR_PK) CMT=3: DV ~ additive(ADD_ERR_PD)Both endpoints contribute to one joint FOCEI/GN likelihood. ODE models only; supported with FOCE/FOCEI, Gauss-Newton, and SAEM.
ferx_model_inspect()reports the per-CMT residual structure. New bundled example:ferx_example("emax_pkpd").[scaling]block in.ferxmodel files for unit conversion. Form A (obs_scale = <number>) divides every model prediction by a scalar before residuals are computed. Form B (obs_scale = <expression>) and Form C (y = <expr>for ODE readout) support parameter and state-variable expressions but requiregradient = fdin[fit_options]. Per-compartment variants (obs_scale[CMT=N] = ...,y[CMT=N] = ...) are also supported. New bundled example:ferx_example("warfarin_scaled").Steady-state dosing via
SSandIIcolumns in the NONMEM CSV. SetSS = 1on a dose row and supply the dosing intervalII(same time units as TIME). The engine resolves steady-state initial conditions analytically for 1/2/3-cpt models and via pulse expansion for ODE models.SS = 2adds the steady-state concentration to the current compartment state (superposition). No model-file changes are required. New bundled example:ferx_example("warfarin_ss").SAEM HMC proposals: pass
settings = list(n_leapfrog = <int>)toferx_fit()to use Hamiltonian Monte Carlo proposals in the SAEM E-step. New output fieldfit$saem_n_subjects_hmcreports the number of subjects that used HMC proposals;NULLfor MH-only or non-SAEM fits.SAEM fully supports inter-occasion variability (IOV / kappa) models. New bundled example:
ferx_example("warfarin_iov_saem").New bundled example
ferx_example("transit_2cpt"): two-compartment ODE model with 3-transit-compartment absorption and allometric scaling.ferx_fit()accepts"imp"as a chained method (e.g.method = c("focei", "imp")ormethod = c("saem", "imp")). The terminal IMP stage runs an importance-sampling estimate of the marginal-2 log L, exposed onfit$importance_sampling(a list withminus2_log_likelihood,mc_standard_error,n_samples,proposal_df,ess_min/ess_median,kappa_treatment, and parallellow_ess_subject_ids/low_ess_subject_fracvectors).print(fit)andsummary(fit)render the new block. New IMP-specific settings keys are recognized byferx_fit(settings = ...):imp_samples,imp_proposal_df,imp_seed,imp_low_ess_threshold. Requires ferx-core with importance-sampling support merged (FeRx-NLME/ferx-core IMP PR).New
stagnation_guardkey recognized byferx_fit(settings = ...). Passsettings = list(stagnation_guard = FALSE)to disable the NLopt outer-loop stagnation guard so SLSQP / L-BFGS run to their own xtol / ftol or tomaxiterinstead of short-circuiting on a numerically-flat OFV plateau. Useful for debugging or for problems with very slow but real OFV improvements below the guard’s 1e-3 threshold. Consumed by FOCE / FOCEI / GN-hybrid only. Requires ferx-core with PR FeRx-NLME/ferx-core#62 merged.
Bug fixes
SAEM no longer collapses the random-effect variances (Omega) on sparsely sampled data. Previously, with few observations per subject, the between-subject variability could shrink toward zero during the first iterations while the residual error absorbed it (tiny
omega, inflated additivesigma). A burn-in now holds Omega fixed while the MH sampler warms up, tunable viasettings = list(omega_burnin = <int>)(default 20;0restores the previous behaviour). Requires the matching ferx-core update that adds the SAEM Omega burn-in.SIR confidence intervals now work correctly for models with
FIX-ed parameters. Previously, any fixed parameter caused the proposal covariance to be singular, and SIR returned “All SIR samples had invalid weights”. Sampling is now restricted to the free-parameter subspace and fixed parameters are held at their estimated values throughout. Requires ferx-core >= 0.1.0 (commit 47b48b5, ferx-core#64).All output functions now display the declared variable name (
ETA_CL,EPS_PROP) rather than wrapping it inOMEGA()/SIGMA(). Affected surfaces:print(fit)OMEGA section and shrinkage,ferx_estimates(),ferx_cor_matrix()(viafit$cov_matrixdimnames),fit$omegarow/column names,fit$sir_ci_omega,fit$sir_ci_sigma, andsummary(fit)shrinkage. When names are absent the fallback remains the conventional numbered form:OMEGA(1,1),SIGMA(1). (#19)
New features
IWRES autocorrelation diagnostic:
fit$dw_statistic(pooled Durbin-Watson) andfit$iwres_lag1_r(pooled lag-1 Pearson r) are now returned byferx_fit(). A--- Diagnostics ---block is printed byprint(fit)when the values are available; an actionablemessage()is emitted when DW < 1.5 or DW > 2.5. The newcheck_diagnostics(fit)function returns a structured list with an$autocorrelationdata frame and a tidy$shrinkagedata frame covering both ETA and EPS components. Both fields round-trip throughferx_save/ferx_load; old.fitrxfiles deserialize withNA. Requires ferx-core ≥ 0.1.0 (commit 5653ddae, ferx-core#20). (#6)SDE support via Extended Kalman Filter: models with a
[diffusion]block in the.ferxfile now run through the EKF likelihood.fit$uses_sdeisTRUEfor these fits; diffusion variance parameters appear infit$thetaasDIFF_<STATE>(e.g.DIFF_CENTRAL). Autodiff is automatically forced to finite differences for SDE models; SAEM is not supported and raises a hard error. Requires ferx-core ≥ 0.1.0 (commit 03332951).Lag time parameter (
lagtime=on the structural_model line, NONMEM- stylealag=accepted as an alias) is now supported in.ferxmodels. Delays the effective start of every dose record by the parameter’s value; defaults to0.0when omitted so existing models are unaffected. Random effects on lag time work the same as on any other PK parameter (LAGTIME = TVLAGTIME * exp(ETA_LAGTIME)for log-normal, or the additive form covered in theparameter-transformsvignette). Pairs with ferx-core#12.ferx_sir(fit)— run SIR (Sampling Importance Resampling) as a standalone post-fit step, without having to setsir = TRUEat fit time. Useful for expensive fits where you want to add SIR-based uncertainty after the fact, or when working with a fit loaded viaferx_load_fit().ferx_fit()now recordsmodel_path/data_pathand SHA-256model_hash/data_hashon the fit; the hashes round-trip through.fitrxsave/load andferx_sir()refuses to run when either file has changed since the fit.ferx_simulate_with_uncertainty()— simulate observations while propagating population parameter uncertainty in addition to the usual between-subject (eta) and residual (eps) variability. For each parameter set drawn from the uncertainty distribution (method = "asymptotic"usesfit$cov_matrix;method = "sir"reuses SIR resamples) the standard simulator runsn_sim_per_drawreplicates. Output is a long data frame with a leadingDRAWcolumn so downstream code can compute uncertainty-aware prediction bands. Requirescovariance = TRUEfor asymptotic mode; SIR mode requiressir = TRUEandsir_keep_samples = TRUE(passed viasettings) at fit time.ferx_fit()now also exposessir_resamples,sir_resamples_n, andsir_resamples_dimfor downstream consumers. Pairs with ferx-core#7.ferx_simulate()output now includes a leadingDRAWcolumn (always1for non-uncertainty paths) for forward compatibility withferx_simulate_with_uncertainty(). Downstream code that usessubset(sim, ...)or column selection by name is unaffected.ferx_fit()now returns$gradient_used— the inner-loop gradient method the engine actually used ("ad","fd", or"N/A"). Whengradient = "auto"it shows which branch resolved at fit time. The raw engine labels are also exposed as$gradient_method_inner/$gradient_method_outer.print()shows both requested and used;summary()formats them asauto (requested) -> ad (used)(ferx-core#1).
Breaking changes
ferx_fit()no longer has dedicatedmax_unconverged_fracandmin_obs_for_convergence_checkarguments. These are estimation knobs and now flow throughsettings, like the other low-level fit options (#51):# before ferx_fit(m, d, max_unconverged_frac = 0.1, min_obs_for_convergence_check = 2L) # after ferx_fit(m, d, settings = list( max_unconverged_frac = 0.1, min_obs_for_convergence_check = 2L ))ferx_model()argument order is nowferx_model(data, model)(data first). This enables the natural data-first pipe entry pointex$data |> ferx_model(ex$model) |> ferx_fit()(#81).Old-style positional calls (
ferx_model("pk.ferx")orferx_model("pk.ferx", "data.csv")) are detected by the.ferxextension on what is now thedataargument and auto-corrected with a deprecation warning. The compatibility shim will be removed in a future release. Calls that passdataby name (ferx_model("pk.ferx", data = "data.csv")) keep working unchanged because R matchesdata =by name first and the remaining positional argument falls into themodelslot.
Bug fixes
Bundled example
warfarin_additive_eta.ferxusedtlag=TLAGon its structural_model line.tlagwas never a recognized PK parameter key in the engine, so the parser silently interpreted the value as thecl=parameter, overwriting the clearance value and producing incorrect fits. Updated tolagtime=TLAG. If you derived a local model from this example, changetlag=tolagtime=(oralag=). Pairs with ferx-core#12.ferx_model_validate()no longer flags[initial_values]as a missing required section. The block was removed from the engine in ferx-core e5e934d — theta / omega / sigma initial values are read from[parameters]and the parser silently ignores any leftover[initial_values]block. The R-side validator andferx_model_new()templates hadn’t been updated. Now: validator’srequired_sectionsdropsinitial_values, all fiveferx_model_new()templates and every bundled.ferxexample file emit the trimmed shape, and a regression test guards against the block creeping back into templates (#16).ferx_set_section()now applies copy-on-write when the underlyingferx_modelpoints at a file inside the installedferxpackage directory (e.g. a model returned byferx_example()). The file is copied totempdir()before editing and the returnedferx_model’s$modelfield is updated to the copy, preventing accidental modification of bundled examples. Plain-path callers are unaffected — passing a path string still edits in place (#80).ferx_check_init()now accepts aferx_modelas its first argument (in addition to a plain path), so it can be placed directly in a pipe:ex$data |> ferx_model(ex$model) |> ferx_check_init(). When aferx_modelis supplied anddatais not, the data path on the object is used (#79).
Documentation
- New vignette “Editing ferx model files programmatically” covering
ferx_model_new(),ferx_model_section(), andferx_model_set_section(): skeleton creation, section inspection, read-modify-write patterns, a console-only fit workflow, and overwrite-guard behaviour.
ferx 0.1.2
New features
New
ferx_model_validate(path)checks a.ferxfile for syntax errors and missing required sections without running the optimizer. Prints a section presence report and returnsTRUE/FALSEinvisibly. Required sections are[parameters],[individual_parameters],[structural_model],[error_model], and[initial_values];[odes]and[fit_options]are optional.ferx_fit()now returns$eigenvalues(sorted descending) and$condition_number(ratio of largest to smallest eigenvalue) for the covariance correlation matrix. Both areNULLwhen the covariance step was not run or failed.condition_number = Infsignals a non-positive eigenvalue. A warning is appended whencondition_number > 1000. The condition number is shown on theCovariance:line inprint()andsummary()output.ferx_model_inspect(path)parses a.ferxfile without fitting and prints a compact structural summary (model type, IIV, IOV, residual error). Pass aferx_fitobject instead of a path to inspect the structure post-fit without re-supplying the file.ferx_fit()now attaches$model_structureto every result: a named list with fieldstheta_names,model_type,iiv,iov, andresidual. The same summary is shown inprint()andsummary()output.ferx_model_section(path, section)— extract and print the body of a named section from a.ferxmodel file; returns lines invisibly for scripted use.ferx_model_set_section(path, section, lines)— replace the body of a named section in-place; the write complement toferx_model_section().
Documentation
- New vignette “Model workflow: inspect, edit, fit” (
vignette("model-workflow", package = "ferx")) demonstrates the pre-fit inspection workflow:ferx_model_inspect()beforeferx_fit()to catch structural mistakes cheaply, and re-inspecting the fitted result viaferx_model_inspect(fit)(closes #57).
Changes to existing functions
ferx_fit()now returns$sigma_namesand$sigma_types(parallel to$sigma), andprint()displays each sigma with its declared name, the derived variance (sigma^2), and — for proportional components — the CV% (sigma * 100). Sigma is on the standard-deviation scale for both proportional and additive components, matching the new YAML output added in ferx-core#57. Closes #59.result$model_structureis now sourced from the Rust engine (built from the parsedCompiledModel) instead of an R-side regex re-parse of the.ferxfile (closes ferx-core#49). The shape is unchanged —theta_names,model_type,iiv,iov,residual— soferx_model_inspect()callers see the same fields.model_typenow distinguishes IV bolus from infusion (e.g."1-cpt IV infusion") and adds 3-cpt variants; the pre-fitferx_model_inspect(path)parser was updated to the same label set so both the file-based and fit-based inspection paths report identical strings.ferx_model_edit()gainsoverwrite = FALSE. Previously the function silently skipped the file copy when the destination already existed; it now errors with a clear message. Callers that relied on the silent-skip must addoverwrite = TRUE.ferx_model_new()gainsprint = FALSE. Whenprint = TRUEthe skeleton is printed to the console without writing any file or opening an editor;pathbecomes optional in that mode. Five templates are available:"1cpt_oral"(default),"1cpt_iv","2cpt_oral","2cpt_iv","ode".
Bug fixes
print.ferx_fit()now uses the exact coefficient of variation formula forEXP(OMEGA)log-normal parameters:sqrt(exp(omega) - 1) * 100instead of the approximationsqrt(omega) * 100(doi:10.1002/psp4.12507). Applied only wheneta_param_types == "log_normal"(defaults to log-normal when the field is absent). Display for logit, additive, and custom ETA types is deferred to #53.ferx_model_section(): fixed a descending-index bug where an empty section body (header immediately followed by another header) returned lines in reverse instead ofcharacter(0).ferx_model_set_section(): fixed a last-section splice bug whereseq.int(end+1, length)produced a descending sequence and appendedNAplus a duplicate line when replacing the last section in a file.ferx_estimates():estimate_naturalis nowNAwhen SE is unavailable, matching the documented contract that all natural-scale columns areNAwhen the covariance step was not run. Previously the back-transform was always populated forlogandlogitthetas regardless of SE..ferx_model_type()(used byferx_model_inspect()): now returns"X-cpt IV infusion"for*_infusionPK models, matching the label the Rust engine attaches post-fit. Previously the pre-fit label dropped theIVtoken.print.ferx_fit(): the logit-ETA+/-1SDsummary line is now ASCII; the previous±rendered as<U+00B1>under non-UTF-8 locales.