From d18b822ad3cae743ec7bb14e77857b4fb60ae248 Mon Sep 17 00:00:00 2001 From: mattfidler Date: Sat, 11 Jul 2026 01:58:38 -0500 Subject: [PATCH 01/18] feat(solve): external parameter-block loader hooks (rxRegisterParLoader) Add a registry of package-supplied loader callbacks that rxode2 invokes once per solve, after gpars is populated and before the parallel integration, so a package can overwrite reserved par_ptr slots with externally-owned values (e.g. neural-network weights held in a torch module, trained outside nlmixr2). - rxRegisterParLoader / rxRemoveParLoader registered as C callables; typedef t_rxParLoader and prototypes exported in inst/include/rxode2.h. - Loaders run single-threaded at the assignGpars call site; gpars is passed with npars and ncols (>= nsub*nsim and nPopPar) so a population-constant block is written to every column. Co-Authored-By: Claude Fable 5 --- inst/include/rxode2.h | 10 ++++++++++ src/init.c | 6 ++++++ src/rxData.cpp | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/inst/include/rxode2.h b/inst/include/rxode2.h index fd70b6bc9..f397378cd 100644 --- a/inst/include/rxode2.h +++ b/inst/include/rxode2.h @@ -107,6 +107,16 @@ void rxode2AdjointTrajSweep(double *tg, double *J, double *dP, int ns, int np, rx_solve *getRxSolve_(void); void rxSetSolveAtolRtol(double atol, double rtol); void rxGetSolveAtolRtol(double *atol, double *rtol); + +// External parameter-block loader hooks. A package registers a callback that +// rxode2 invokes once per solve, after the parameter vector (gpars) is filled +// and before integration, so the package can overwrite reserved par_ptr slots +// with externally-owned values (e.g. neural-network weights). gpars is laid +// out `npars` per column with `ncols` columns; write a population-constant +// block to every column. +typedef void (*t_rxParLoader)(rx_solve *rx, double *gpars, int npars, int ncols); +void rxRegisterParLoader(t_rxParLoader cb); +void rxRemoveParLoader(t_rxParLoader cb); #endif rx_solve *getRxSolve2_(void); rx_solve *getRxSolve(SEXP ptr); diff --git a/src/init.c b/src/init.c index 78b2f5c20..41ea53262 100644 --- a/src/init.c +++ b/src/init.c @@ -276,6 +276,10 @@ void rxOptionsIni(void); void _update_par_ptr(double t, unsigned int id, rx_solve *rx, int idx); double _getParCov(unsigned int id, rx_solve *rx, int parNo, int idx); +typedef void (*t_rxParLoader)(rx_solve *rx, double *gpars, int npars, int ncols); +void rxRegisterParLoader(t_rxParLoader cb); +void rxRemoveParLoader(t_rxParLoader cb); + int par_progress(int c, int n, int d, int cores, clock_t t0, int stop); void ind_solve(rx_solve *rx, unsigned int cid, t_dydt_liblsoda dydt_lls, t_dydt_lsoda_dum dydt_lsoda, t_jdum_lsoda jdum, @@ -982,6 +986,8 @@ void R_init_rxode2(DllInfo *info){ R_RegisterCCallable("rxode2", "ind_solve", (DL_FUNC) &ind_solve); R_RegisterCCallable("rxode2", "par_solve", (DL_FUNC) &par_solve); R_RegisterCCallable("rxode2", "_update_par_ptr", (DL_FUNC) &_update_par_ptr); + R_RegisterCCallable("rxode2", "rxRegisterParLoader", (DL_FUNC) &rxRegisterParLoader); + R_RegisterCCallable("rxode2", "rxRemoveParLoader", (DL_FUNC) &rxRemoveParLoader); R_RegisterCCallable("rxode2", "_getParCov", (DL_FUNC) &_getParCov); R_RegisterCCallable("rxode2","rxRmModelLib", (DL_FUNC) &rxRmModelLib); R_RegisterCCallable("rxode2","rxGetModelLib", (DL_FUNC) &rxGetModelLib); diff --git a/src/rxData.cpp b/src/rxData.cpp index 16957701b..3329cf406 100644 --- a/src/rxData.cpp +++ b/src/rxData.cpp @@ -4501,6 +4501,41 @@ static inline void rxSolve_parOrder(const RObject &obj, const List &rxControl, static inline void rxSolve_assignGpars(rxSolve_t* rxSolveDat); +// ---- external parameter-block loader hooks -------------------------------- +// Packages (e.g. rxode2nn) register a callback that runs once per solve, after +// gpars is populated from the supplied parameters and before integration, so +// they can overwrite reserved par_ptr slots with externally-owned values (e.g. +// neural-network weights held in a torch module). This runs single-threaded, +// before the parallel per-subject solve, so callbacks writing gpars are safe. +// gpars is laid out npars per column with `ncols` columns (>= nsub*nsim and +// nPopPar); a population-constant block is written to every column. +typedef void (*t_rxParLoader)(rx_solve* rx, double* gpars, int npars, int ncols); +#define RX_MAX_PAR_LOADERS 32 +static t_rxParLoader _rxParLoaders[RX_MAX_PAR_LOADERS] = {NULL}; +static int _rxNParLoaders = 0; + +extern "C" void rxRegisterParLoader(t_rxParLoader cb) { + if (cb == NULL) return; + for (int i = 0; i < _rxNParLoaders; ++i) if (_rxParLoaders[i] == cb) return; + if (_rxNParLoaders < RX_MAX_PAR_LOADERS) _rxParLoaders[_rxNParLoaders++] = cb; +} + +extern "C" void rxRemoveParLoader(t_rxParLoader cb) { + for (int i = 0; i < _rxNParLoaders; ++i) { + if (_rxParLoaders[i] == cb) { + for (int k = i; k < _rxNParLoaders - 1; ++k) _rxParLoaders[k] = _rxParLoaders[k + 1]; + _rxParLoaders[--_rxNParLoaders] = NULL; + return; + } + } +} + +static inline void rxCallParLoaders(rx_solve* rx, int npars, int ncols) { + for (int i = 0; i < _rxNParLoaders; ++i) { + _rxParLoaders[i](rx, &_globals.gpars[0], npars, ncols); + } +} + static inline void rxSolve_resample(const RObject &obj, const List &rxControl, const Nullable &specParams, @@ -4696,6 +4731,11 @@ static inline void rxSolve_normalizeParms(const RObject &obj, const List &rxCont { gparsCovSetup(rx->npars, rxSolveDat->nPopPar, rx->nsub*rx->nsim, ev1, rx); rxSolve_assignGpars(rxSolveDat); + { + int _ncols = (int)(rx->nsub*rx->nsim); + if (rxSolveDat->nPopPar > _ncols) _ncols = rxSolveDat->nPopPar; + rxCallParLoaders(rx, rx->npars, _ncols); + } rxSolve_resample(obj, rxControl, specParams, extraArgs, pars, ev1, inits, rxSolveDat); curSolve=0; From a38f7b873410e7d2948729f18e8b9dcca30a7109 Mon Sep 17 00:00:00 2001 From: mattfidler Date: Sat, 11 Jul 2026 07:56:10 -0500 Subject: [PATCH 02/18] feat(ptr): expose par-loader register/remove via the function-pointer table Move rxRegisterParLoader / rxRemoveParLoader off R_RegisterCCallable and onto rxode2's positional external-pointer table (indices 81/82), the CRAN-preferred, ABI-safe cross-package mechanism (see CLAUDE.md). t_rxParLoader moves outside the __RXODE2PTR_H__ guard so downstream packages see the typedef; rxode2ptr.h gains the two typedefs, iniRxodePtrs0 assignments, and NULL-inits. Co-Authored-By: Claude Fable 5 --- inst/include/rxode2.h | 19 ++++++++++++------- inst/include/rxode2ptr.h | 12 ++++++++++++ src/init.c | 17 ++++++++++------- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/inst/include/rxode2.h b/inst/include/rxode2.h index f397378cd..ba738196b 100644 --- a/inst/include/rxode2.h +++ b/inst/include/rxode2.h @@ -89,6 +89,15 @@ typedef rx_solve *(*t_get_solve)(void); typedef void *(*t_assignFuns)(void); +// External parameter-block loader hook signature. A package registers a +// callback (via the rxode2 function-pointer table) that rxode2 invokes once per +// solve, after gpars is filled and before integration, so it can overwrite +// reserved par_ptr slots with externally-owned values (e.g. neural-network +// weights). gpars is laid out `npars` per column with `ncols` columns; write a +// population-constant block to every column. Defined outside the +// __RXODE2PTR_H__ guard so downstream packages see the typedef. +typedef void (*t_rxParLoader)(rx_solve *rx, double *gpars, int npars, int ncols); + // Adjoint objective-gradient backward sweep (src/adjoint.cpp). Its address is // exported to downstream packages through the rxode2 function-pointer table // (see _rxode2_rxode2Ptr in src/init.c and rxode2ptr.h); this direct declaration @@ -108,13 +117,9 @@ rx_solve *getRxSolve_(void); void rxSetSolveAtolRtol(double atol, double rtol); void rxGetSolveAtolRtol(double *atol, double *rtol); -// External parameter-block loader hooks. A package registers a callback that -// rxode2 invokes once per solve, after the parameter vector (gpars) is filled -// and before integration, so the package can overwrite reserved par_ptr slots -// with externally-owned values (e.g. neural-network weights). gpars is laid -// out `npars` per column with `ncols` columns; write a population-constant -// block to every column. -typedef void (*t_rxParLoader)(rx_solve *rx, double *gpars, int npars, int ncols); +// External parameter-block loader hook register/remove (t_rxParLoader typedef +// is above, outside the guard). Direct declarations for building rxode2 +// itself; downstream packages get these as function pointers via the table. void rxRegisterParLoader(t_rxParLoader cb); void rxRemoveParLoader(t_rxParLoader cb); #endif diff --git a/inst/include/rxode2ptr.h b/inst/include/rxode2ptr.h index 278aa4ffc..7277b42db 100644 --- a/inst/include/rxode2ptr.h +++ b/inst/include/rxode2ptr.h @@ -270,6 +270,14 @@ extern "C" { typedef void (*setRxThreadId_t)(int id); extern setRxThreadId_t setRxThreadId; + // Register / remove an external parameter-block loader (t_rxParLoader from + // rxode2.h); rxode2 calls registered loaders once per solve so a package can + // fill reserved par_ptr slots with externally-owned values. + typedef void (*rxRegisterParLoader_t)(t_rxParLoader cb); + extern rxRegisterParLoader_t rxRegisterParLoader; + typedef void (*rxRemoveParLoader_t)(t_rxParLoader cb); + extern rxRemoveParLoader_t rxRemoveParLoader; + static inline SEXP iniRxodePtrs0(SEXP p) { if (_rxode2_rxRmvnSEXP_ == NULL) { _rxode2_rxRmvnSEXP_ = (_rxode2_rxRmvnSEXP_t) R_ExternalPtrAddrFn(VECTOR_ELT(p, 0)); @@ -353,6 +361,8 @@ extern "C" { setIndSolveLast = (setIndSolveLast_t) R_ExternalPtrAddrFn(VECTOR_ELT(p, 78)); getIndSolveLast2 = (getIndSolveLast2_t) R_ExternalPtrAddrFn(VECTOR_ELT(p, 79)); setIndSolveLast2 = (setIndSolveLast2_t) R_ExternalPtrAddrFn(VECTOR_ELT(p, 80)); + rxRegisterParLoader = (rxRegisterParLoader_t) R_ExternalPtrAddrFn(VECTOR_ELT(p, 81)); + rxRemoveParLoader = (rxRemoveParLoader_t) R_ExternalPtrAddrFn(VECTOR_ELT(p, 82)); } return R_NilValue; } @@ -439,6 +449,8 @@ extern "C" { setIndSolveLast_t setIndSolveLast = NULL; \ getIndSolveLast2_t getIndSolveLast2 = NULL; \ setIndSolveLast2_t setIndSolveLast2 = NULL; \ + rxRegisterParLoader_t rxRegisterParLoader = NULL; \ + rxRemoveParLoader_t rxRemoveParLoader = NULL; \ SEXP iniRxodePtrs(SEXP ptr) { \ return iniRxodePtrs0(ptr); \ } \ diff --git a/src/init.c b/src/init.c index 41ea53262..bc260d616 100644 --- a/src/init.c +++ b/src/init.c @@ -275,10 +275,9 @@ void rxOptionsIni(void); void _update_par_ptr(double t, unsigned int id, rx_solve *rx, int idx); double _getParCov(unsigned int id, rx_solve *rx, int parNo, int idx); - -typedef void (*t_rxParLoader)(rx_solve *rx, double *gpars, int npars, int ncols); -void rxRegisterParLoader(t_rxParLoader cb); -void rxRemoveParLoader(t_rxParLoader cb); +/* rxRegisterParLoader / rxRemoveParLoader + t_rxParLoader come from rxode2.h; + they are exported to downstream packages via the function-pointer table + (_rxode2_rxode2Ptr below), not R_RegisterCCallable. */ int par_progress(int c, int n, int d, int cores, clock_t t0, int stop); void ind_solve(rx_solve *rx, unsigned int cid, t_dydt_liblsoda dydt_lls, @@ -523,8 +522,10 @@ SEXP _rxode2_rxode2Ptr(void) { SEXP rxode2setIndSolveLast = PROTECT(R_MakeExternalPtrFn((DL_FUNC)&setIndSolveLast, R_NilValue, R_NilValue)); pro++; SEXP rxode2getIndSolveLast2 = PROTECT(R_MakeExternalPtrFn((DL_FUNC)&getIndSolveLast2, R_NilValue, R_NilValue)); pro++; SEXP rxode2setIndSolveLast2 = PROTECT(R_MakeExternalPtrFn((DL_FUNC)&setIndSolveLast2, R_NilValue, R_NilValue)); pro++; + SEXP rxode2rxRegisterParLoader = PROTECT(R_MakeExternalPtrFn((DL_FUNC)&rxRegisterParLoader, R_NilValue, R_NilValue)); pro++; + SEXP rxode2rxRemoveParLoader = PROTECT(R_MakeExternalPtrFn((DL_FUNC)&rxRemoveParLoader, R_NilValue, R_NilValue)); pro++; -#define nVec 81 +#define nVec 83 SEXP ret = PROTECT(Rf_allocVector(VECSXP, nVec)); pro++; SET_VECTOR_ELT(ret, 0, rxode2rxRmvnSEXP); SET_VECTOR_ELT(ret, 1, rxode2rxParProgress); @@ -607,6 +608,8 @@ SEXP _rxode2_rxode2Ptr(void) { SET_VECTOR_ELT(ret, 78, rxode2setIndSolveLast); SET_VECTOR_ELT(ret, 79, rxode2getIndSolveLast2); SET_VECTOR_ELT(ret, 80, rxode2setIndSolveLast2); + SET_VECTOR_ELT(ret, 81, rxode2rxRegisterParLoader); + SET_VECTOR_ELT(ret, 82, rxode2rxRemoveParLoader); SEXP retN = PROTECT(Rf_allocVector(STRSXP, nVec)); pro++; @@ -691,6 +694,8 @@ SEXP _rxode2_rxode2Ptr(void) { SET_STRING_ELT(retN, 78, Rf_mkChar("rxode2setIndSolveLast")); SET_STRING_ELT(retN, 79, Rf_mkChar("rxode2getIndSolveLast2")); SET_STRING_ELT(retN, 80, Rf_mkChar("rxode2setIndSolveLast2")); + SET_STRING_ELT(retN, 81, Rf_mkChar("rxode2rxRegisterParLoader")); + SET_STRING_ELT(retN, 82, Rf_mkChar("rxode2rxRemoveParLoader")); #undef nVec @@ -986,8 +991,6 @@ void R_init_rxode2(DllInfo *info){ R_RegisterCCallable("rxode2", "ind_solve", (DL_FUNC) &ind_solve); R_RegisterCCallable("rxode2", "par_solve", (DL_FUNC) &par_solve); R_RegisterCCallable("rxode2", "_update_par_ptr", (DL_FUNC) &_update_par_ptr); - R_RegisterCCallable("rxode2", "rxRegisterParLoader", (DL_FUNC) &rxRegisterParLoader); - R_RegisterCCallable("rxode2", "rxRemoveParLoader", (DL_FUNC) &rxRemoveParLoader); R_RegisterCCallable("rxode2", "_getParCov", (DL_FUNC) &_getParCov); R_RegisterCCallable("rxode2","rxRmModelLib", (DL_FUNC) &rxRmModelLib); R_RegisterCCallable("rxode2","rxGetModelLib", (DL_FUNC) &rxGetModelLib); From 46222148941ca2481d2e580092dfcbb53d114c36 Mon Sep 17 00:00:00 2001 From: mattfidler Date: Sat, 11 Jul 2026 07:59:16 -0500 Subject: [PATCH 03/18] test(par-loader): verify multiple loaders are applied in series rxRegisterParLoader supports several registered loaders (a fixed-capacity array cycled in rxCallParLoaders). Add test-only sentinel loaders A/B (write 111/222 to parameters 0/1) with _rxode2_rxRegisterTestParLoaders/_rxRemoveTestParLoaders, and a testthat test: two loaders both apply in series, one applies alone, and removal restores pass-through. Co-Authored-By: Claude Fable 5 --- src/init.c | 5 ++++ src/rxData.cpp | 25 ++++++++++++++++ tests/testthat/test-par-loader.R | 51 ++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 tests/testthat/test-par-loader.R diff --git a/src/init.c b/src/init.c index bc260d616..f19cb8c73 100644 --- a/src/init.c +++ b/src/init.c @@ -742,9 +742,14 @@ SEXP _rxode2_mlogit_j(SEXP x); SEXP _rxode2_rxMemoryComponents_(SEXP,SEXP,SEXP,SEXP,SEXP,SEXP,SEXP,SEXP,SEXP,SEXP,SEXP,SEXP,SEXP,SEXP,SEXP,SEXP,SEXP,SEXP,SEXP); SEXP _rxode2_rxSolveSetCurObj_(SEXP); +extern SEXP _rxode2_rxRegisterTestParLoaders(SEXP); +extern SEXP _rxode2_rxRemoveTestParLoaders(void); + void R_init_rxode2(DllInfo *info){ allocExtraDosingC(); R_CallMethodDef callMethods[] = { + {"_rxode2_rxRegisterTestParLoaders", (DL_FUNC) &_rxode2_rxRegisterTestParLoaders, 1}, + {"_rxode2_rxRemoveTestParLoaders", (DL_FUNC) &_rxode2_rxRemoveTestParLoaders, 0}, {"_rxode2_qsDes", (DL_FUNC) &_rxode2_qsDes, 1}, {"_rxode2_rxGetSerialType_", (DL_FUNC) &_rxode2_rxGetSerialType_, 1}, {"_rxode2_mlogit_f", (DL_FUNC) &_rxode2_mlogit_f, 2}, diff --git a/src/rxData.cpp b/src/rxData.cpp index 3329cf406..e1a848e48 100644 --- a/src/rxData.cpp +++ b/src/rxData.cpp @@ -4536,6 +4536,31 @@ static inline void rxCallParLoaders(rx_solve* rx, int npars, int ncols) { } } +// Test-only par loaders (tests/testthat/test-par-loader.R): confirm that +// multiple registered loaders are applied in series -- A writes a sentinel to +// parameter 0, B to parameter 1. +extern "C" void rxTestParLoaderA(rx_solve* rx, double* gpars, int npars, int ncols) { + (void) rx; + if (npars < 1) return; + for (int c = 0; c < ncols; ++c) gpars[(size_t) c * npars + 0] = 111.0; +} +extern "C" void rxTestParLoaderB(rx_solve* rx, double* gpars, int npars, int ncols) { + (void) rx; + if (npars < 2) return; + for (int c = 0; c < ncols; ++c) gpars[(size_t) c * npars + 1] = 222.0; +} +extern "C" SEXP _rxode2_rxRegisterTestParLoaders(SEXP nSEXP) { + int n = Rf_asInteger(nSEXP); + rxRegisterParLoader(rxTestParLoaderA); + if (n >= 2) rxRegisterParLoader(rxTestParLoaderB); + return R_NilValue; +} +extern "C" SEXP _rxode2_rxRemoveTestParLoaders(void) { + rxRemoveParLoader(rxTestParLoaderA); + rxRemoveParLoader(rxTestParLoaderB); + return R_NilValue; +} + static inline void rxSolve_resample(const RObject &obj, const List &rxControl, const Nullable &specParams, diff --git a/tests/testthat/test-par-loader.R b/tests/testthat/test-par-loader.R new file mode 100644 index 000000000..54758566a --- /dev/null +++ b/tests/testthat/test-par-loader.R @@ -0,0 +1,51 @@ +## External parameter-block loader hooks (rxRegisterParLoader): a package can +## register callbacks that rxode2 invokes once per solve, after gpars is filled +## and before integration, to overwrite reserved par_ptr slots. Verifies that +## MULTIPLE registered loaders are applied in series (test loaders A and B write +## sentinels 111 and 222 to parameters 0 and 1). + +rxTest({ + + test_that("multiple par-loaders are applied in series", { + + .m <- rxode2({ + param(a, b) + oa <- a + ob <- b + d/dt(x) <- 0 + }) + ## a, b are the first two parameters -> par_ptr indices 0 and 1 + .pars <- rxModelVars(.m)$params + expect_equal(.pars[1], "a") + expect_equal(.pars[2], "b") + + .ev <- et(amt = 0) |> et(0, 1, by = 1) + + ## baseline: supplied parameter values pass through unchanged + .s0 <- rxSolve(.m, .ev, params = c(a = 1, b = 2), returnType = "data.frame") + expect_equal(.s0$oa[1], 1) + expect_equal(.s0$ob[1], 2) + + ## register two loaders -> both overwrite their slot, in series + .Call("_rxode2_rxRegisterTestParLoaders", 2L, PACKAGE = "rxode2") + on.exit(.Call("_rxode2_rxRemoveTestParLoaders", PACKAGE = "rxode2"), add = TRUE) + + .s <- rxSolve(.m, .ev, params = c(a = 1, b = 2), returnType = "data.frame") + expect_equal(.s$oa[1], 111) # loader A wrote parameter 0 + expect_equal(.s$ob[1], 222) # loader B wrote parameter 1 (second in series) + + ## a single loader overwrites only its slot + .Call("_rxode2_rxRemoveTestParLoaders", PACKAGE = "rxode2") + .Call("_rxode2_rxRegisterTestParLoaders", 1L, PACKAGE = "rxode2") + .s1 <- rxSolve(.m, .ev, params = c(a = 1, b = 2), returnType = "data.frame") + expect_equal(.s1$oa[1], 111) # loader A only + expect_equal(.s1$ob[1], 2) + + ## removing the loaders restores pass-through behavior + .Call("_rxode2_rxRemoveTestParLoaders", PACKAGE = "rxode2") + .s2 <- rxSolve(.m, .ev, params = c(a = 1, b = 2), returnType = "data.frame") + expect_equal(.s2$oa[1], 1) + expect_equal(.s2$ob[1], 2) + }) + +}) From b8637e3b8901242acaf7a038b1b784b6efb5f357 Mon Sep 17 00:00:00 2001 From: mattfidler Date: Sat, 11 Jul 2026 15:18:30 -0500 Subject: [PATCH 04/18] feat(solve): save/restore par-loader-injected parameters on the solved object Parameters written by a par-loader hook (e.g. trained neural-network weights supplied out-of-band rather than in the params vector) are now captured on the solved object and restored when re-solving from it -- so a solved/fitted object reproduces those values even in a session without the injecting package. - rxCallParLoaders diffs the population parameter block before/after the loaders to record exactly which parameters were injected; _rxode2_rxGetInjectedPars returns them (0-based indices + values). - .rxSolveMaterializeParams saves them on the object as .injectedPars (mapped to parameter names); rxInjectedPars() exposes them. - rxSolve.rxSolve applies them (override) when re-solving from a solved object. - test-par-loader.R: inject -> save on object -> remove loaders -> re-solve restores the injected values; a non-injected solve reports NULL. Co-Authored-By: Claude Fable 5 --- NAMESPACE | 1 + R/rxsolve.R | 47 ++++++++++++++++++++++++++++++++ src/init.c | 2 ++ src/rxData.cpp | 36 ++++++++++++++++++++++++ tests/testthat/test-par-loader.R | 35 ++++++++++++++++++++++++ 5 files changed, 121 insertions(+) diff --git a/NAMESPACE b/NAMESPACE index 7a0b87ffa..9a876bade 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -647,6 +647,7 @@ export(rxInit) export(rxInits) export(rxIntToBase) export(rxIntToLetter) +export(rxInjectedPars) export(rxInv) export(rxIs) export(rxIsAutoSwitch) diff --git a/R/rxsolve.R b/R/rxsolve.R index 82b333a7c..4bd9f032c 100644 --- a/R/rxsolve.R +++ b/R/rxsolve.R @@ -3346,17 +3346,32 @@ rxSolve.rxSolve <- function(object, params = NULL, events = NULL, inits = NULL, # Pass the rxSolve object directly so C++ updates the correct object # rather than the global rxCurObj, which may point to a different rxSolve. if (is.null(params)) params <- object$.params.single + params <- .rxApplyInjectedPars(params, object) if (is.null(inits)) inits <- object$inits return(rxSolve.default(object, params = params, events = events, inits = inits, ..., theta = theta, eta = eta, envir = envir)) } .model <- as.character(.model) if (is.null(params)) params <- object$.params.single + params <- .rxApplyInjectedPars(params, object) if (is.null(inits)) inits <- object$inits rxSolve(.model, params = params, events = events, inits = inits, ..., theta = theta, eta = eta, envir = envir) } +## Restore par-loader-injected parameters (e.g. trained NN weights) onto the +## params for a re-solve, so re-solving from a solved object reproduces them +## even without the injecting package. Injected values override for their +## names (and are appended if absent); only applies to a single named vector. +.rxApplyInjectedPars <- function(params, object) { + .inj <- rxInjectedPars(object) + if (is.null(.inj) || length(.inj) == 0L) return(params) + if (is.numeric(params) && is.null(dim(params)) && !is.null(names(params))) { + params[names(.inj)] <- .inj + } + params +} + #' @rdname rxSolve #' @export predict.rxode2 <- function(object, ...) { @@ -3539,9 +3554,41 @@ solve.rxEt <- solve.rxSolve dadt = get(".dadt.counter", envir = .env, inherits = FALSE), jac = get(".jac.counter", envir = .env, inherits = FALSE) ), envir = .env) + ## Parameters injected by par-loaders on this solve (e.g. trained neural-network + ## weights supplied through a loader hook rather than the params vector). Saved + ## on the object so re-solving restores them even in a session without the + ## injecting package's buffer. Indices are 0-based into the model params. + .inj <- tryCatch(.Call(`_rxode2_rxGetInjectedPars`), error = function(e) NULL) + if (!is.null(.inj) && length(.inj[[1L]]) > 0L) { + .injNames <- .pars[.inj[[1L]] + 1L] + assign(".injectedPars", stats::setNames(.inj[[2L]], .injNames), envir = .env) + } else { + assign(".injectedPars", NULL, envir = .env) + } invisible(TRUE) } +#' Parameters injected into a solve by a par-loader hook +#' +#' Returns the parameters (e.g. trained neural-network weights) that a +#' registered par-loader wrote into the solve parameter vector, saved on the +#' solved object so re-solving from it restores them. +#' +#' @param obj a solved rxode2 object. +#' @return a named numeric vector, or `NULL` if nothing was injected. +#' @export +rxInjectedPars <- function(obj) { + .cls <- attr(obj, "class") + .env <- attr(.cls, ".rxode2.env") + if (is.null(.env)) return(NULL) + .rxSolveMaterializeParams(obj, .env) # ensure materialized + if (exists(".injectedPars", envir = .env, inherits = FALSE)) { + get(".injectedPars", envir = .env, inherits = FALSE) + } else { + NULL + } +} + .rxSolveGetInit <- function(.env, arg) { .ini <- get(".init.dat", envir = .env, inherits = FALSE) if (arg %in% c("inits", "init")) { diff --git a/src/init.c b/src/init.c index 205664784..7e18135a0 100644 --- a/src/init.c +++ b/src/init.c @@ -748,12 +748,14 @@ SEXP _rxode2_rxSolveSetCurObj_(SEXP); extern SEXP _rxode2_rxRegisterTestParLoaders(SEXP); extern SEXP _rxode2_rxRemoveTestParLoaders(void); +extern SEXP _rxode2_rxGetInjectedPars(void); void R_init_rxode2(DllInfo *info){ allocExtraDosingC(); R_CallMethodDef callMethods[] = { {"_rxode2_rxRegisterTestParLoaders", (DL_FUNC) &_rxode2_rxRegisterTestParLoaders, 1}, {"_rxode2_rxRemoveTestParLoaders", (DL_FUNC) &_rxode2_rxRemoveTestParLoaders, 0}, + {"_rxode2_rxGetInjectedPars", (DL_FUNC) &_rxode2_rxGetInjectedPars, 0}, {"_rxode2_qsDes", (DL_FUNC) &_rxode2_qsDes, 1}, {"_rxode2_rxGetSerialType_", (DL_FUNC) &_rxode2_rxGetSerialType_, 1}, {"_rxode2_mlogit_f", (DL_FUNC) &_rxode2_mlogit_f, 2}, diff --git a/src/rxData.cpp b/src/rxData.cpp index e1a848e48..103b5f52a 100644 --- a/src/rxData.cpp +++ b/src/rxData.cpp @@ -4530,10 +4530,46 @@ extern "C" void rxRemoveParLoader(t_rxParLoader cb) { } } +// Parameters injected by the loaders on the most recent solve, captured by +// diffing the population parameter block (column 0) before/after the loaders. +// Lets the solved object save/restore externally-injected values (e.g. trained +// neural-network weights) so re-solving from the object reproduces them even in +// a session where the injecting package's buffer is gone. +static std::vector _rxInjIdx; +static std::vector _rxInjVal; + static inline void rxCallParLoaders(rx_solve* rx, int npars, int ncols) { + _rxInjIdx.clear(); + _rxInjVal.clear(); + if (_rxNParLoaders == 0) return; + static std::vector pre; + pre.assign(&_globals.gpars[0], &_globals.gpars[0] + npars); // subject 0 block for (int i = 0; i < _rxNParLoaders; ++i) { _rxParLoaders[i](rx, &_globals.gpars[0], npars, ncols); } + for (int k = 0; k < npars; ++k) { + if (_globals.gpars[k] != pre[k]) { + _rxInjIdx.push_back(k); + _rxInjVal.push_back(_globals.gpars[k]); + } + } +} + +// Injected params from the last solve as list(index0 = <0-based par indices>, +// value = ); the R layer maps indices to parameter names. +extern "C" SEXP _rxode2_rxGetInjectedPars(void) { + int n = (int) _rxInjIdx.size(); + SEXP idx = PROTECT(Rf_allocVector(INTSXP, n)); + SEXP val = PROTECT(Rf_allocVector(REALSXP, n)); + for (int i = 0; i < n; ++i) { + INTEGER(idx)[i] = _rxInjIdx[i]; + REAL(val)[i] = _rxInjVal[i]; + } + SEXP ret = PROTECT(Rf_allocVector(VECSXP, 2)); + SET_VECTOR_ELT(ret, 0, idx); + SET_VECTOR_ELT(ret, 1, val); + UNPROTECT(3); + return ret; } // Test-only par loaders (tests/testthat/test-par-loader.R): confirm that diff --git a/tests/testthat/test-par-loader.R b/tests/testthat/test-par-loader.R index 54758566a..9a0344309 100644 --- a/tests/testthat/test-par-loader.R +++ b/tests/testthat/test-par-loader.R @@ -48,4 +48,39 @@ rxTest({ expect_equal(.s2$ob[1], 2) }) + test_that("injected parameters are saved on the object and restored on re-solve", { + + .m <- rxode2({ + param(a, b) + oa <- a + ob <- b + d/dt(x) <- 0 + }) + .ev <- et(amt = 0) |> et(0, 1, by = 1) + + ## solve with two loaders injecting 111 -> a, 222 -> b + .Call("_rxode2_rxRegisterTestParLoaders", 2L, PACKAGE = "rxode2") + .obj <- rxSolve(.m, .ev, params = c(a = 1, b = 2)) + + ## the injected values are saved on the solved object + .inj <- rxInjectedPars(.obj) + expect_equal(.inj[["a"]], 111) + expect_equal(.inj[["b"]], 222) + + ## remove the loaders: a plain solve passes the supplied params through + .Call("_rxode2_rxRemoveTestParLoaders", PACKAGE = "rxode2") + .plain <- rxSolve(.m, .ev, params = c(a = 1, b = 2), returnType = "data.frame") + expect_equal(.plain$oa[1], 1) + expect_equal(.plain$ob[1], 2) + + ## re-solving from the saved object restores the injected values, even though + ## no loader is registered anymore + .re <- rxSolve(.obj, .ev, returnType = "data.frame") + expect_equal(.re$oa[1], 111) + expect_equal(.re$ob[1], 222) + + ## a model with no injection reports nothing + expect_null(rxInjectedPars(.plain)) + }) + }) From 571c528ebd6d9c8b1900591014d7bc20542687d1 Mon Sep 17 00:00:00 2001 From: mattfidler Date: Sat, 11 Jul 2026 21:11:09 -0500 Subject: [PATCH 05/18] feat(solve): dydt forcing hook for plugin state-derivative forcing Add a registry of dydt forcing hooks the generated model invokes at the end of its RHS (dydt) evaluation, letting a plugin ADD forcing to state derivatives -- the par-loader analogue for the RHS. This is the mechanism NN-weight forward-sensitivity (variational) states use to receive their b_j term on top of the J*s_j part rxode2's sensitivity codegen already produces. - rxRegisterDydtForce / rxRemoveDydtForce exposed to downstream packages via the function-pointer table (indices 84/85, nVec 86); rxCallDydtForce R_RegisterCCallable'd for generated model code. - codegen: model resolves rxCallDydtForce at DLL load and calls it at the end of dydt (no-op branch when nothing is registered, so normal models are unaffected). Declaration + resolution added to the rxode2_model_shared.c template so the generated codegen2.h picks them up. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UaQkwAM1PeW5msx6EFSyKD --- inst/include/rxode2.h | 6 +++++ inst/include/rxode2_model_shared.c | 2 ++ inst/include/rxode2_model_shared.h | 5 +++++ inst/include/rxode2ptr.h | 13 +++++++++++ src/codegen.c | 1 + src/init.c | 14 +++++++++++- src/rxData.cpp | 35 ++++++++++++++++++++++++++++++ 7 files changed, 75 insertions(+), 1 deletion(-) diff --git a/inst/include/rxode2.h b/inst/include/rxode2.h index ba738196b..de6ae8835 100644 --- a/inst/include/rxode2.h +++ b/inst/include/rxode2.h @@ -98,6 +98,10 @@ typedef void *(*t_assignFuns)(void); // __RXODE2PTR_H__ guard so downstream packages see the typedef. typedef void (*t_rxParLoader)(rx_solve *rx, double *gpars, int npars, int ncols); +// dydt forcing hook: add forcing to state derivatives from generated model dydt. +// Defined outside the __RXODE2PTR_H__ guard so downstream packages see the typedef. +typedef void (*t_rxDydtForce)(int *neq, double t, double *y, double *dydt); + // Adjoint objective-gradient backward sweep (src/adjoint.cpp). Its address is // exported to downstream packages through the rxode2 function-pointer table // (see _rxode2_rxode2Ptr in src/init.c and rxode2ptr.h); this direct declaration @@ -122,6 +126,8 @@ void rxGetSolveAtolRtol(double *atol, double *rtol); // itself; downstream packages get these as function pointers via the table. void rxRegisterParLoader(t_rxParLoader cb); void rxRemoveParLoader(t_rxParLoader cb); +void rxRegisterDydtForce(t_rxDydtForce cb); +void rxRemoveDydtForce(t_rxDydtForce cb); #endif rx_solve *getRxSolve2_(void); rx_solve *getRxSolve(SEXP ptr); diff --git a/inst/include/rxode2_model_shared.c b/inst/include/rxode2_model_shared.c index fc7dd8050..efb68539a 100644 --- a/inst/include/rxode2_model_shared.c +++ b/inst/include/rxode2_model_shared.c @@ -20,6 +20,7 @@ _update_par_ptr_p _update_par_ptr=NULL; _getParCov_p _getParCov=NULL; _setThreadInd_t _setThreadInd=NULL; _rxPushDose_t _rxPushDose=NULL; +_rxDydtForce_t _rxDydtForceCb=NULL; linCmtA_p linCmtA; linCmtB_p linCmtB; _rx_asgn _rxode2_rxAssignPtr =NULL; @@ -603,6 +604,7 @@ void _assignFuns0(void) { _getParCov = (_getParCov_p) R_GetCCallable("rxode2","_getParCov"); _setThreadInd = (_setThreadInd_t) R_GetCCallable("rxode2","_setThreadInd"); _rxPushDose = (_rxPushDose_t) R_GetCCallable("rxode2","_rxPushDose"); + _rxDydtForceCb= (_rxDydtForce_t) R_GetCCallable("rxode2","rxCallDydtForce"); // dynamic start linCmtA=(linCmtA_p)R_GetCCallable("rxode2", "linCmtA"); linCmtB=(linCmtB_p)R_GetCCallable("rxode2", "linCmtB"); diff --git a/inst/include/rxode2_model_shared.h b/inst/include/rxode2_model_shared.h index ab761b9f9..0a920a799 100644 --- a/inst/include/rxode2_model_shared.h +++ b/inst/include/rxode2_model_shared.h @@ -382,6 +382,11 @@ typedef double (*linCmtB_p) (rx_solve *rx, int id, typedef void (*_update_par_ptr_p)(double t, unsigned int id, rx_solve *rx, int idx); +/* dydt forcing hook: generated model calls this at the end of its RHS (dydt) so a + plugin can add forcing to state derivatives (e.g. b_j for NN-weight variational + states). neq[0]=nstate, neq[1]=cSub; y=states, dydt=DADT array to add into. */ +typedef void (*_rxDydtForce_t)(int *neq, double t, double *y, double *dydt); + typedef double (*_getParCov_p)(unsigned int id, rx_solve *rx, int parNo, int idx); typedef rx_solve *(*_getRxSolve_t)(void); diff --git a/inst/include/rxode2ptr.h b/inst/include/rxode2ptr.h index 6d708e95e..e703dae50 100644 --- a/inst/include/rxode2ptr.h +++ b/inst/include/rxode2ptr.h @@ -280,6 +280,15 @@ extern "C" { typedef void (*rxRemoveParLoader_t)(t_rxParLoader cb); extern rxRemoveParLoader_t rxRemoveParLoader; + // Register / remove a dydt forcing hook (t_rxDydtForce from rxode2.h); the + // generated model calls all registered hooks at the end of its RHS so a + // package can add forcing to state derivatives (e.g. NN-weight variational + // states). + typedef void (*rxRegisterDydtForce_t)(t_rxDydtForce cb); + extern rxRegisterDydtForce_t rxRegisterDydtForce; + typedef void (*rxRemoveDydtForce_t)(t_rxDydtForce cb); + extern rxRemoveDydtForce_t rxRemoveDydtForce; + static inline SEXP iniRxodePtrs0(SEXP p) { if (_rxode2_rxRmvnSEXP_ == NULL) { _rxode2_rxRmvnSEXP_ = (_rxode2_rxRmvnSEXP_t) R_ExternalPtrAddrFn(VECTOR_ELT(p, 0)); @@ -366,6 +375,8 @@ extern "C" { setIndSolveLast2 = (setIndSolveLast2_t) R_ExternalPtrAddrFn(VECTOR_ELT(p, 80)); rxRegisterParLoader = (rxRegisterParLoader_t) R_ExternalPtrAddrFn(VECTOR_ELT(p, 82)); rxRemoveParLoader = (rxRemoveParLoader_t) R_ExternalPtrAddrFn(VECTOR_ELT(p, 83)); + rxRegisterDydtForce = (rxRegisterDydtForce_t) R_ExternalPtrAddrFn(VECTOR_ELT(p, 84)); + rxRemoveDydtForce = (rxRemoveDydtForce_t) R_ExternalPtrAddrFn(VECTOR_ELT(p, 85)); } return R_NilValue; } @@ -455,6 +466,8 @@ extern "C" { setIndSolveLast2_t setIndSolveLast2 = NULL; \ rxRegisterParLoader_t rxRegisterParLoader = NULL; \ rxRemoveParLoader_t rxRemoveParLoader = NULL; \ + rxRegisterDydtForce_t rxRegisterDydtForce = NULL; \ + rxRemoveDydtForce_t rxRemoveDydtForce = NULL; \ SEXP iniRxodePtrs(SEXP ptr) { \ return iniRxodePtrs0(ptr); \ } \ diff --git a/src/codegen.c b/src/codegen.c index 6abda0416..bb297bda5 100644 --- a/src/codegen.c +++ b/src/codegen.c @@ -755,6 +755,7 @@ void codegen(char *model, int show_ode, const char *prefix, const char *libname, } } if (show_ode == ode_dydt){ + sAppendN(&sbOut, " if (_rxDydtForceCb != NULL) _rxDydtForceCb(_neq, __t, __zzStateVar__, __DDtStateVar__);\n", 90); sAppendN(&sbOut, " (&_solveData->subjects[_cSub])->dadt_counter[0]++;\n}\n\n", 56); } else if (show_ode == ode_jac){ //sAppendN(&sbOut, " free(__ld_DDtStateVar__);\n"); diff --git a/src/init.c b/src/init.c index 7e18135a0..018923e82 100644 --- a/src/init.c +++ b/src/init.c @@ -279,6 +279,11 @@ double _getParCov(unsigned int id, rx_solve *rx, int parNo, int idx); they are exported to downstream packages via the function-pointer table (_rxode2_rxode2Ptr below), not R_RegisterCCallable. */ +/* dydt forcing hook: rxCallDydtForce is called from generated model code and so + is resolved via R_GetCCallable; rxRegisterDydtForce / rxRemoveDydtForce are + exported to plugins via the function-pointer table (like the par loaders). */ +void rxCallDydtForce(int *neq, double t, double *y, double *dydt); + int par_progress(int c, int n, int d, int cores, clock_t t0, int stop); void ind_solve(rx_solve *rx, unsigned int cid, t_dydt_liblsoda dydt_lls, t_dydt_lsoda_dum dydt_lsoda, t_jdum_lsoda jdum, @@ -526,8 +531,10 @@ SEXP _rxode2_rxode2Ptr(void) { SEXP rxode2setIndSolveLast2 = PROTECT(R_MakeExternalPtrFn((DL_FUNC)&setIndSolveLast2, R_NilValue, R_NilValue)); pro++; SEXP rxode2rxRegisterParLoader = PROTECT(R_MakeExternalPtrFn((DL_FUNC)&rxRegisterParLoader, R_NilValue, R_NilValue)); pro++; SEXP rxode2rxRemoveParLoader = PROTECT(R_MakeExternalPtrFn((DL_FUNC)&rxRemoveParLoader, R_NilValue, R_NilValue)); pro++; + SEXP rxode2rxRegisterDydtForce = PROTECT(R_MakeExternalPtrFn((DL_FUNC)&rxRegisterDydtForce, R_NilValue, R_NilValue)); pro++; + SEXP rxode2rxRemoveDydtForce = PROTECT(R_MakeExternalPtrFn((DL_FUNC)&rxRemoveDydtForce, R_NilValue, R_NilValue)); pro++; -#define nVec 84 +#define nVec 86 SEXP ret = PROTECT(Rf_allocVector(VECSXP, nVec)); pro++; SET_VECTOR_ELT(ret, 0, rxode2rxRmvnSEXP); SET_VECTOR_ELT(ret, 1, rxode2rxParProgress); @@ -613,6 +620,8 @@ SEXP _rxode2_rxode2Ptr(void) { SET_VECTOR_ELT(ret, 81, rxode2rxUnifEng); SET_VECTOR_ELT(ret, 82, rxode2rxRegisterParLoader); SET_VECTOR_ELT(ret, 83, rxode2rxRemoveParLoader); + SET_VECTOR_ELT(ret, 84, rxode2rxRegisterDydtForce); + SET_VECTOR_ELT(ret, 85, rxode2rxRemoveDydtForce); SEXP retN = PROTECT(Rf_allocVector(STRSXP, nVec)); pro++; @@ -700,6 +709,8 @@ SEXP _rxode2_rxode2Ptr(void) { SET_STRING_ELT(retN, 81, Rf_mkChar("rxode2rxUnifEng")); SET_STRING_ELT(retN, 82, Rf_mkChar("rxode2rxRegisterParLoader")); SET_STRING_ELT(retN, 83, Rf_mkChar("rxode2rxRemoveParLoader")); + SET_STRING_ELT(retN, 84, Rf_mkChar("rxode2rxRegisterDydtForce")); + SET_STRING_ELT(retN, 85, Rf_mkChar("rxode2rxRemoveDydtForce")); #undef nVec @@ -1002,6 +1013,7 @@ void R_init_rxode2(DllInfo *info){ R_RegisterCCallable("rxode2", "ind_solve", (DL_FUNC) &ind_solve); R_RegisterCCallable("rxode2", "par_solve", (DL_FUNC) &par_solve); R_RegisterCCallable("rxode2", "_update_par_ptr", (DL_FUNC) &_update_par_ptr); + R_RegisterCCallable("rxode2", "rxCallDydtForce", (DL_FUNC) &rxCallDydtForce); R_RegisterCCallable("rxode2", "_getParCov", (DL_FUNC) &_getParCov); R_RegisterCCallable("rxode2","rxRmModelLib", (DL_FUNC) &rxRmModelLib); R_RegisterCCallable("rxode2","rxGetModelLib", (DL_FUNC) &rxGetModelLib); diff --git a/src/rxData.cpp b/src/rxData.cpp index 103b5f52a..9bb01fbe5 100644 --- a/src/rxData.cpp +++ b/src/rxData.cpp @@ -4530,6 +4530,41 @@ extern "C" void rxRemoveParLoader(t_rxParLoader cb) { } } +// ---- dydt forcing hooks --------------------------------------------------- +// Packages register a callback the generated model invokes at the end of its RHS +// (dydt) evaluation, so they can ADD forcing to designated state derivatives -- +// e.g. the b_j = dR/dg * dg/dw_j term for NN-weight forward-sensitivity +// (variational) states, on top of the J*s_j part rxode2's sensitivity codegen +// already produces. Called inside the per-subject solve (possibly parallel), so +// callbacks must be thread-safe (read-only shared state, write only their own +// dydt slots). neq[0]=nstate, neq[1]=cSub. +typedef void (*t_rxDydtForce)(int *neq, double t, double *y, double *dydt); +#define RX_MAX_DYDT_FORCE 32 +static t_rxDydtForce _rxDydtForce[RX_MAX_DYDT_FORCE] = {NULL}; +static int _rxNDydtForce = 0; + +extern "C" void rxRegisterDydtForce(t_rxDydtForce cb) { + if (cb == NULL) return; + for (int i = 0; i < _rxNDydtForce; ++i) if (_rxDydtForce[i] == cb) return; + if (_rxNDydtForce < RX_MAX_DYDT_FORCE) _rxDydtForce[_rxNDydtForce++] = cb; +} + +extern "C" void rxRemoveDydtForce(t_rxDydtForce cb) { + for (int i = 0; i < _rxNDydtForce; ++i) { + if (_rxDydtForce[i] == cb) { + for (int k = i; k < _rxNDydtForce - 1; ++k) _rxDydtForce[k] = _rxDydtForce[k + 1]; + _rxDydtForce[--_rxNDydtForce] = NULL; + return; + } + } +} + +// Invoked by generated model dydt (resolved via R_GetCCallable at model load). +// Fast no-op when nothing is registered -- one branch per RHS evaluation. +extern "C" void rxCallDydtForce(int *neq, double t, double *y, double *dydt) { + for (int i = 0; i < _rxNDydtForce; ++i) _rxDydtForce[i](neq, t, y, dydt); +} + // Parameters injected by the loaders on the most recent solve, captured by // diffing the population parameter block (column 0) before/after the loaders. // Lets the solved object save/restore externally-injected values (e.g. trained From 2358f7128ba6c3a0321126e3c928b5fa29e215c3 Mon Sep 17 00:00:00 2001 From: mattfidler Date: Mon, 13 Jul 2026 16:09:08 -0500 Subject: [PATCH 06/18] feat(tran): etaFD() model directive -- force finite-difference eta sensitivity Adds the etaFD() model-block directive baked into modelVars$etaFD (per-param 0/1 flag, RxMv_etaFD=33), with parser plumbing (tran.g grammar + parse*.h), genModelVars emission, rxData char->modelVars builder, mu-ref/symengine directive allowlisting, and rxUiGet.etaFDLines. Used by nlmixr2est to force FD sensitivity of an eta (e.g. invisible NN-weight etas). Co-Authored-By: Claude Opus 4.8 --- NAMESPACE | 1 + R/err-sim.R | 17 + R/mu.R | 9 + R/symengine.R | 2 +- inst/include/rxode2parse_control.h | 1 + inst/tran.g | 3 + src/genModelVars.c | 21 +- src/genModelVars.h | 3 +- src/parseCmtProperties.h | 12 + src/parseIdentifier.h | 1 + src/parseStatements.h | 17 + src/parseVars.h | 1 + src/rxData.cpp | 7 +- src/tran.c | 5 + src/tran.g.d_parser.h | 11051 ++++++++++++++------------- src/tran.h | 4 + 16 files changed, 5698 insertions(+), 5457 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 9a876bade..2c0a8de50 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -242,6 +242,7 @@ S3method(rxUiGet,funPrint) S3method(rxUiGet,funTxt) S3method(rxUiGet,ini) S3method(rxUiGet,iniFun) +S3method(rxUiGet,etaFDLines) S3method(rxUiGet,interpLines) S3method(rxUiGet,levelLhs) S3method(rxUiGet,levels) diff --git a/R/err-sim.R b/R/err-sim.R index 34b0b467d..ffcb47a07 100644 --- a/R/err-sim.R +++ b/R/err-sim.R @@ -323,6 +323,23 @@ rxUiGet.interpLines <- function(x, ...){ attr(rxUiGet.interpLines, "desc") <- "interpolation declaration line(s) for model" attr(rxUiGet.interpLines, "rstudio") <- quote(linear(CP)) # for rstudio completion +#' @export +#' @rdname rxUiGet +rxUiGet.etaFDLines <- function(x, ...){ + .ui <- x[[1]] + .etaFD <- rxModelVars(.ui)$etaFD + if (is.null(.etaFD) || length(.etaFD) == 0L) { + return(NULL) + } + .w <- which(.etaFD == 1L) + if (length(.w) == 0L) { + return(NULL) + } + list(str2lang(paste0("etaFD(", paste(names(.etaFD)[.w], collapse=", "), ")"))) +} +attr(rxUiGet.etaFDLines, "desc") <- "etaFD (force finite-difference eta) declaration line(s) for model" +attr(rxUiGet.etaFDLines, "rstudio") <- quote(etaFD(eta.cl)) # for rstudio completion + #' @rdname rxUiGet #' @export rxUiGet.splitDose <- function(x, ...) { diff --git a/R/mu.R b/R/mu.R index b1d2aeb4c..16ec044f2 100644 --- a/R/mu.R +++ b/R/mu.R @@ -750,6 +750,15 @@ #' @noRd .rxMuRefHandleNonPlusCall <- function(x, env) { .curEval <- as.character(x[[1]]) + # Directive statements (param/interp/cmt/etaFD/...) are pass-through model + # lines, not mathematical expressions -- do not descend into them looking for + # mu-referenced parameters (etaFD() in particular lists a bare eta argument). + if (length(.curEval) == 1L && + .curEval %in% c("param", "params", "cmt", "dvid", "locf", "nocb", + "linear", "midpoint", "interp", "etaFD", "splitBolus", + "matExp", "indLin")) { + return(invisible()) + } assign(".curEval", .curEval, env) env$curHi <- NA_real_ env$curLow <- NA_real_ diff --git a/R/symengine.R b/R/symengine.R index f9877590b..8f5d32c08 100644 --- a/R/symengine.R +++ b/R/symengine.R @@ -2213,7 +2213,7 @@ rxToSE <- function(x, envir = NULL, progress = FALSE, } } else { if (.fun %in% c("param", "dvid", "cmt", "locf", "nocb", - "midpoint", "linear", "splitBolus", "matExp", "indLin")) return(NULL) + "midpoint", "linear", "etaFD", "splitBolus", "matExp", "indLin")) return(NULL) if (.fun %in% c("printf", "Rprintf", "print")) { return(paste(deparse(x), collapse="")) } diff --git a/inst/include/rxode2parse_control.h b/inst/include/rxode2parse_control.h index 43c020e78..ba4387be5 100644 --- a/inst/include/rxode2parse_control.h +++ b/inst/include/rxode2parse_control.h @@ -163,6 +163,7 @@ #define RxMv_strCmpParams 30 #define RxMv_timeId 31 #define RxMv_md5 32 +#define RxMv_etaFD 33 #define RxMvFlag_ncmt 0 #define RxMvFlag_ka 1 #define RxMvFlag_linB 2 diff --git a/inst/tran.g b/inst/tran.g index de7602593..4610f5a5b 100644 --- a/inst/tran.g +++ b/inst/tran.g @@ -24,6 +24,7 @@ statement | printf_statement end_statement | param_statement end_statement | interp_statement end_statement + | etaFD_statement end_statement | cmt_statement end_statement | splitBolus_statement end_statement | dvid_statementI end_statement @@ -141,6 +142,8 @@ param_statement interp_statement: ('locf' | 'linear' | 'nocb' | 'midpoint') '(' (identifier_r | theta0 | theta | eta) (',' (identifier_r | theta0 | theta | eta) )* ')'; +etaFD_statement: "etaFD" '(' (identifier_r | theta0 | theta | eta) (',' (identifier_r | theta0 | theta | eta) )* ')'; + printf_statement : printf_command '(' string (',' logical_or_expression )* ')'; diff --git a/src/genModelVars.c b/src/genModelVars.c index a32a3852d..b7df26380 100644 --- a/src/genModelVars.c +++ b/src/genModelVars.c @@ -11,8 +11,8 @@ SEXP generateModelVars(void) { calcNextra(); rxProtectGuard; - SEXP lst = rxP(Rf_allocVector(VECSXP, 31)); - SEXP names = rxP(Rf_allocVector(STRSXP, 31)); + SEXP lst = rxP(Rf_allocVector(VECSXP, 34)); + SEXP names = rxP(Rf_allocVector(STRSXP, 34)); SEXP sNeedSort = rxP(Rf_allocVector(INTSXP,1)); int *iNeedSort = INTEGER(sNeedSort); @@ -61,6 +61,7 @@ SEXP generateModelVars(void) { SEXP lhsOrd = rxP(Rf_allocVector(INTSXP, tb.li)); SEXP slhs = rxP(Rf_allocVector(STRSXP, tb.sli)); SEXP interp = rxP(Rf_allocVector(INTSXP, tb.pi)); + SEXP etaFD = rxP(Rf_allocVector(INTSXP, tb.pi)); SEXP version = rxP(calcVersionInfo()); SEXP ini = rxP(calcIniVals()); @@ -68,7 +69,7 @@ SEXP generateModelVars(void) { SEXP model = rxP(Rf_allocVector(STRSXP,2)); SEXP modeln = rxP(Rf_allocVector(STRSXP,2)); - populateParamsLhsSlhs(params, lhsIn, slhs, INTEGER(interp), lhsStrIn, + populateParamsLhsSlhs(params, lhsIn, slhs, INTEGER(interp), INTEGER(etaFD), lhsStrIn, INTEGER(lhsOrd)); SEXP lhsOrdFS = rxP(orderForderS1(lhsOrd)); @@ -322,6 +323,20 @@ SEXP generateModelVars(void) { SET_VECTOR_ELT(lst, 21, interp); SET_STRING_ELT(names, 21, Rf_mkChar("interp")); + // timeId (31) and md5 (32) placeholders -- filled by name in R (rxModelVars); + // created here so etaFD stays at the canonical end (33) of the model-vars list + SET_VECTOR_ELT(lst, 31, Rf_ScalarInteger(0)); + SET_STRING_ELT(names, 31, Rf_mkChar("timeId")); + SEXP md5Ph = rxP(Rf_allocVector(STRSXP, 2)); + SET_STRING_ELT(md5Ph, 0, Rf_mkChar("")); + SET_STRING_ELT(md5Ph, 1, Rf_mkChar("")); + SET_VECTOR_ELT(lst, 32, md5Ph); + SET_STRING_ELT(names, 32, Rf_mkChar("md5")); + + Rf_setAttrib(etaFD, R_NamesSymbol, params); + SET_VECTOR_ELT(lst, 33, etaFD); + SET_STRING_ELT(names, 33, Rf_mkChar("etaFD")); + SEXP strAssign = rxP(Rf_allocVector(VECSXP, tb.str.n)); SEXP strAssignN = rxP(Rf_allocVector(STRSXP, tb.str.n)); for (int i = 0; i < tb.str.n; i++) { diff --git a/src/genModelVars.h b/src/genModelVars.h index 3b84b6542..0d9e49461 100644 --- a/src/genModelVars.h +++ b/src/genModelVars.h @@ -496,7 +496,7 @@ static inline void assertLhsAndDualLhsDiffNotLegal(int islhs, int i, char *buf) } } -static inline void populateParamsLhsSlhs(SEXP params, SEXP lhs, SEXP slhs, int *interp, SEXP lhsStr, int *lhsOrd) { +static inline void populateParamsLhsSlhs(SEXP params, SEXP lhs, SEXP slhs, int *interp, int *etaFD, SEXP lhsStr, int *lhsOrd) { int li=0, pi=0, sli = 0; char *buf; for (int i=0; isplitBolus_statement = -1; ni->param_statement = -1; ni->interp_statement = -1; + ni->etaFD_statement = -1; ni->dvid_statementI = -1; ni->ifelse = -1; ni->ifelse_statement=-1; From 37a94549f9b8ccd047f7444e7b3880547f7717bd Mon Sep 17 00:00:00 2001 From: mattfidler Date: Mon, 13 Jul 2026 20:02:57 -0500 Subject: [PATCH 07/18] fix(ui): refresh the eta list from the iniDf after UDF parsing A rxUdfUi modification function can append etas to the model iniDf during parsing (e.g. individual neural-network weight etas). .env$eta was set once from the ini({}) omega matrix before those UDFs ran and never refreshed, so UDF-appended etas were present in the (authoritative) iniDf but missing from the eta list -- and thus mis-classified as covariates by mu-referencing / covariate detection (the fit then failed a data-name check). Refresh .env$eta from the final iniDf (ordered by neta1) right after `.env$iniDf <- .env$df`. No-op for models whose etas are all declared in ini({}). Co-Authored-By: Claude Opus 4.8 --- R/err.R | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/R/err.R b/R/err.R index e42825d25..31a1cd197 100644 --- a/R/err.R +++ b/R/err.R @@ -1359,6 +1359,16 @@ rxErrTypeCombine <- function(oldErrType, newErrType) { .i <- .i + 1L } .env$iniDf <- .env$df + # A UDF modification function (rxUdfUi) can append etas to the iniDf during + # parsing (e.g. individual neural-network weight etas). .env$eta was set from + # the ini({}) omega before those UDFs ran, so refresh it from the now-final + # iniDf (the source of truth); otherwise the appended etas are mis-classified + # as covariates downstream (mu-referencing / covariate detection). + .etaDf <- .env$df[!is.na(.env$df$neta1), , drop=FALSE] + if (nrow(.etaDf) > 0L) { + .etaDf <- .etaDf[order(.etaDf$neta1), , drop=FALSE] + .env$eta <- unique(.etaDf$name) + } if (is.null(.env$predDf)) { ## .env$errGlobal <- c(.env$errGlobal, ## "there must be at least one prediction in the model({}) block. Use `~` for predictions") From 26ab3144df021a329ec7402c65b5e17f0624c432 Mon Sep 17 00:00:00 2001 From: mattfidler Date: Mon, 13 Jul 2026 22:23:22 -0500 Subject: [PATCH 08/18] Phase 0: revert etaFD() model directive Reverts 2358f7128. The etaFD() directive existed only to force finite-difference sensitivity of invisible NN-weight etas (nni); the DeepPumas-style latent-input random effect uses analytic input derivatives instead, so the directive is unused. Shrinks modelVars back to 33 elements (drops RxMv_etaFD=33), so nlmixr2est must be rebuilt. The generated tran.g.d_parser.h reverts to its pre-etaFD state (grammar was untouched since that commit). The unrelated eta-refresh fix in err.R is retained. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MmVREEBZmrR1nfv3cFUd39 --- NAMESPACE | 1 - R/err-sim.R | 17 - R/mu.R | 9 - R/symengine.R | 2 +- inst/include/rxode2parse_control.h | 1 - inst/tran.g | 3 - src/genModelVars.c | 21 +- src/genModelVars.h | 3 +- src/parseCmtProperties.h | 12 - src/parseIdentifier.h | 1 - src/parseStatements.h | 17 - src/parseVars.h | 1 - src/rxData.cpp | 7 +- src/tran.c | 5 - src/tran.g.d_parser.h | 11051 +++++++++++++-------------- src/tran.h | 4 - 16 files changed, 5457 insertions(+), 5698 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 2c0a8de50..9a876bade 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -242,7 +242,6 @@ S3method(rxUiGet,funPrint) S3method(rxUiGet,funTxt) S3method(rxUiGet,ini) S3method(rxUiGet,iniFun) -S3method(rxUiGet,etaFDLines) S3method(rxUiGet,interpLines) S3method(rxUiGet,levelLhs) S3method(rxUiGet,levels) diff --git a/R/err-sim.R b/R/err-sim.R index ffcb47a07..34b0b467d 100644 --- a/R/err-sim.R +++ b/R/err-sim.R @@ -323,23 +323,6 @@ rxUiGet.interpLines <- function(x, ...){ attr(rxUiGet.interpLines, "desc") <- "interpolation declaration line(s) for model" attr(rxUiGet.interpLines, "rstudio") <- quote(linear(CP)) # for rstudio completion -#' @export -#' @rdname rxUiGet -rxUiGet.etaFDLines <- function(x, ...){ - .ui <- x[[1]] - .etaFD <- rxModelVars(.ui)$etaFD - if (is.null(.etaFD) || length(.etaFD) == 0L) { - return(NULL) - } - .w <- which(.etaFD == 1L) - if (length(.w) == 0L) { - return(NULL) - } - list(str2lang(paste0("etaFD(", paste(names(.etaFD)[.w], collapse=", "), ")"))) -} -attr(rxUiGet.etaFDLines, "desc") <- "etaFD (force finite-difference eta) declaration line(s) for model" -attr(rxUiGet.etaFDLines, "rstudio") <- quote(etaFD(eta.cl)) # for rstudio completion - #' @rdname rxUiGet #' @export rxUiGet.splitDose <- function(x, ...) { diff --git a/R/mu.R b/R/mu.R index 16ec044f2..b1d2aeb4c 100644 --- a/R/mu.R +++ b/R/mu.R @@ -750,15 +750,6 @@ #' @noRd .rxMuRefHandleNonPlusCall <- function(x, env) { .curEval <- as.character(x[[1]]) - # Directive statements (param/interp/cmt/etaFD/...) are pass-through model - # lines, not mathematical expressions -- do not descend into them looking for - # mu-referenced parameters (etaFD() in particular lists a bare eta argument). - if (length(.curEval) == 1L && - .curEval %in% c("param", "params", "cmt", "dvid", "locf", "nocb", - "linear", "midpoint", "interp", "etaFD", "splitBolus", - "matExp", "indLin")) { - return(invisible()) - } assign(".curEval", .curEval, env) env$curHi <- NA_real_ env$curLow <- NA_real_ diff --git a/R/symengine.R b/R/symengine.R index 8f5d32c08..f9877590b 100644 --- a/R/symengine.R +++ b/R/symengine.R @@ -2213,7 +2213,7 @@ rxToSE <- function(x, envir = NULL, progress = FALSE, } } else { if (.fun %in% c("param", "dvid", "cmt", "locf", "nocb", - "midpoint", "linear", "etaFD", "splitBolus", "matExp", "indLin")) return(NULL) + "midpoint", "linear", "splitBolus", "matExp", "indLin")) return(NULL) if (.fun %in% c("printf", "Rprintf", "print")) { return(paste(deparse(x), collapse="")) } diff --git a/inst/include/rxode2parse_control.h b/inst/include/rxode2parse_control.h index ba4387be5..43c020e78 100644 --- a/inst/include/rxode2parse_control.h +++ b/inst/include/rxode2parse_control.h @@ -163,7 +163,6 @@ #define RxMv_strCmpParams 30 #define RxMv_timeId 31 #define RxMv_md5 32 -#define RxMv_etaFD 33 #define RxMvFlag_ncmt 0 #define RxMvFlag_ka 1 #define RxMvFlag_linB 2 diff --git a/inst/tran.g b/inst/tran.g index 4610f5a5b..de7602593 100644 --- a/inst/tran.g +++ b/inst/tran.g @@ -24,7 +24,6 @@ statement | printf_statement end_statement | param_statement end_statement | interp_statement end_statement - | etaFD_statement end_statement | cmt_statement end_statement | splitBolus_statement end_statement | dvid_statementI end_statement @@ -142,8 +141,6 @@ param_statement interp_statement: ('locf' | 'linear' | 'nocb' | 'midpoint') '(' (identifier_r | theta0 | theta | eta) (',' (identifier_r | theta0 | theta | eta) )* ')'; -etaFD_statement: "etaFD" '(' (identifier_r | theta0 | theta | eta) (',' (identifier_r | theta0 | theta | eta) )* ')'; - printf_statement : printf_command '(' string (',' logical_or_expression )* ')'; diff --git a/src/genModelVars.c b/src/genModelVars.c index b7df26380..a32a3852d 100644 --- a/src/genModelVars.c +++ b/src/genModelVars.c @@ -11,8 +11,8 @@ SEXP generateModelVars(void) { calcNextra(); rxProtectGuard; - SEXP lst = rxP(Rf_allocVector(VECSXP, 34)); - SEXP names = rxP(Rf_allocVector(STRSXP, 34)); + SEXP lst = rxP(Rf_allocVector(VECSXP, 31)); + SEXP names = rxP(Rf_allocVector(STRSXP, 31)); SEXP sNeedSort = rxP(Rf_allocVector(INTSXP,1)); int *iNeedSort = INTEGER(sNeedSort); @@ -61,7 +61,6 @@ SEXP generateModelVars(void) { SEXP lhsOrd = rxP(Rf_allocVector(INTSXP, tb.li)); SEXP slhs = rxP(Rf_allocVector(STRSXP, tb.sli)); SEXP interp = rxP(Rf_allocVector(INTSXP, tb.pi)); - SEXP etaFD = rxP(Rf_allocVector(INTSXP, tb.pi)); SEXP version = rxP(calcVersionInfo()); SEXP ini = rxP(calcIniVals()); @@ -69,7 +68,7 @@ SEXP generateModelVars(void) { SEXP model = rxP(Rf_allocVector(STRSXP,2)); SEXP modeln = rxP(Rf_allocVector(STRSXP,2)); - populateParamsLhsSlhs(params, lhsIn, slhs, INTEGER(interp), INTEGER(etaFD), lhsStrIn, + populateParamsLhsSlhs(params, lhsIn, slhs, INTEGER(interp), lhsStrIn, INTEGER(lhsOrd)); SEXP lhsOrdFS = rxP(orderForderS1(lhsOrd)); @@ -323,20 +322,6 @@ SEXP generateModelVars(void) { SET_VECTOR_ELT(lst, 21, interp); SET_STRING_ELT(names, 21, Rf_mkChar("interp")); - // timeId (31) and md5 (32) placeholders -- filled by name in R (rxModelVars); - // created here so etaFD stays at the canonical end (33) of the model-vars list - SET_VECTOR_ELT(lst, 31, Rf_ScalarInteger(0)); - SET_STRING_ELT(names, 31, Rf_mkChar("timeId")); - SEXP md5Ph = rxP(Rf_allocVector(STRSXP, 2)); - SET_STRING_ELT(md5Ph, 0, Rf_mkChar("")); - SET_STRING_ELT(md5Ph, 1, Rf_mkChar("")); - SET_VECTOR_ELT(lst, 32, md5Ph); - SET_STRING_ELT(names, 32, Rf_mkChar("md5")); - - Rf_setAttrib(etaFD, R_NamesSymbol, params); - SET_VECTOR_ELT(lst, 33, etaFD); - SET_STRING_ELT(names, 33, Rf_mkChar("etaFD")); - SEXP strAssign = rxP(Rf_allocVector(VECSXP, tb.str.n)); SEXP strAssignN = rxP(Rf_allocVector(STRSXP, tb.str.n)); for (int i = 0; i < tb.str.n; i++) { diff --git a/src/genModelVars.h b/src/genModelVars.h index 0d9e49461..3b84b6542 100644 --- a/src/genModelVars.h +++ b/src/genModelVars.h @@ -496,7 +496,7 @@ static inline void assertLhsAndDualLhsDiffNotLegal(int islhs, int i, char *buf) } } -static inline void populateParamsLhsSlhs(SEXP params, SEXP lhs, SEXP slhs, int *interp, int *etaFD, SEXP lhsStr, int *lhsOrd) { +static inline void populateParamsLhsSlhs(SEXP params, SEXP lhs, SEXP slhs, int *interp, SEXP lhsStr, int *lhsOrd) { int li=0, pi=0, sli = 0; char *buf; for (int i=0; isplitBolus_statement = -1; ni->param_statement = -1; ni->interp_statement = -1; - ni->etaFD_statement = -1; ni->dvid_statementI = -1; ni->ifelse = -1; ni->ifelse_statement=-1; From 034afdc2b9050c586d9c4b96c58ffe17af06a655 Mon Sep 17 00:00:00 2001 From: mattfidler Date: Mon, 13 Jul 2026 23:06:31 -0500 Subject: [PATCH 09/18] Phase 1: per-ui forcedPars -- plugin-free solve-time parameter forcing Adds a hidden named-numeric `forcedPars` slot to the rxUi (stored in the model meta env, so it travels through piping and into fits) plus public `rxForcedPars()` / `rxForcedPars<-()` accessors. At solve setup the ui's forced values are resolved to solve-param indices and injected into every gpars column at rxCallParLoaders (rxData.cpp) -- the one point every solve path hits after gpars assignment -- overriding params/data/inits. This is the first-class successor to the global par-loader hook: a model/fit carries its externally-owned values (e.g. trained NN weights) and re-solves, predicts and simulates with them, self-contained and portable. C: _rxForcedIdx/_rxForcedVal buffer + _rxode2_rxSetForcedPars/ClearForcedPars, injected before registered loaders in rxCallParLoaders (also captured by the existing injected-pars diff, so rxInjectedPars() reports them). R: rxForcedPars getter/setter, .rxApplyForcedPars honored in rxSolve.rxUi. Test: test-forced-pars.R (override, unset->unchanged, non-param names ignored, piping carry). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MmVREEBZmrR1nfv3cFUd39 --- NAMESPACE | 4 +- R/rxsolve.R | 85 +++++++++++++++++++++++++++++++ man/rxForcedPars.Rd | 38 ++++++++++++++ man/rxInjectedPars.Rd | 19 +++++++ src/init.c | 4 ++ src/rxData.cpp | 37 +++++++++++++- tests/testthat/test-forced-pars.R | 65 +++++++++++++++++++++++ 7 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 man/rxForcedPars.Rd create mode 100644 man/rxInjectedPars.Rd create mode 100644 tests/testthat/test-forced-pars.R diff --git a/NAMESPACE b/NAMESPACE index 9a876bade..f5e9663b8 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -322,6 +322,7 @@ S3method(vec_restore,rxEt) export("RxODE<-") export("ini<-") export("model<-") +export("rxForcedPars<-") export("rxode2<-") export("rxode<-") export(.assertRenameErrorModelLine) @@ -627,6 +628,7 @@ export(rxExpandSens3_) export(rxExpandSens_) export(rxFixPop) export(rxFixRes) +export(rxForcedPars) export(rxForget) export(rxFromSE) export(rxFun) @@ -645,9 +647,9 @@ export(rxIndLinState) export(rxIndLinStrategy) export(rxInit) export(rxInits) +export(rxInjectedPars) export(rxIntToBase) export(rxIntToLetter) -export(rxInjectedPars) export(rxInv) export(rxIs) export(rxIsAutoSwitch) diff --git a/R/rxsolve.R b/R/rxsolve.R index 4bd9f032c..ac9b10cd6 100644 --- a/R/rxsolve.R +++ b/R/rxsolve.R @@ -2190,6 +2190,11 @@ rxSolve.rxUi <- function(object, params = NULL, events = NULL, inits = NULL, ... .lst$drop <- c(.lst$drop, "ipredSim") } } + ## forced (externally-owned) parameters carried on the ui -- injected into every + ## solve column at setup (rxCallParLoaders), overriding params/data/inits. + if (.rxApplyForcedPars(object, .lst[[1L]])) { + on.exit(.rxClearForcedParsC(), add = TRUE) + } .ret <- do.call("rxSolve.default", .lst) if (.pred) { .e <- attr(class(.ret), ".rxode2.env") @@ -3589,6 +3594,86 @@ rxInjectedPars <- function(obj) { } } +#' Forced parameters carried by a model / ui +#' +#' Get or set a named-numeric vector of *forced* parameters stored in a hidden +#' slot of an rxode2 ui. Forced parameters override the values supplied in +#' `params`/data and the model initial estimates on **every** solve of that ui +#' (they are written into every subject/simulation column at solve setup, the +#' same injection point used by par-loader hooks). Because the values live on +#' the ui they travel with it -- through model piping and into any `nlmixr2` fit +#' built from it -- so the model stays self-contained and portable (e.g. a fit +#' that carries trained neural-network weights re-solves, predicts and simulates +#' with those weights with no external state). +#' +#' Names must be model parameters; names that are not model parameters are +#' ignored at solve time. Set to `NULL` (or an empty vector) to clear. +#' +#' @param ui an rxode2 ui / model function. +#' @param value named numeric vector of forced parameter values, or `NULL`. +#' @return the getter returns the named numeric vector (or `NULL`); the setter +#' returns the (modified) ui. +#' @export +#' @author Matthew L. Fidler +rxForcedPars <- function(ui) { + .ui <- rxUiDecompress(ui) + if (!inherits(.ui, "rxUi")) return(NULL) + .fp <- .ui$forcedPars + if (is.null(.fp)) return(NULL) + .fp +} + +#' @rdname rxForcedPars +#' @export +`rxForcedPars<-` <- function(ui, value) { + .ui <- rxUiDecompress(ui) + if (!inherits(.ui, "rxUi")) { + stop("'ui' must be an rxode2 ui/model to set forcedPars", call. = FALSE) + } + if (is.null(value) || length(value) == 0L) { + if (exists("forcedPars", envir = .ui$meta, inherits = FALSE)) { + rm("forcedPars", envir = .ui$meta) + } + return(invisible(.ui)) + } + if (is.null(names(value)) || any(names(value) == "")) { + stop("forcedPars must be a fully-named numeric vector", call. = FALSE) + } + assign("forcedPars", stats::setNames(as.numeric(value), names(value)), + envir = .ui$meta) + invisible(.ui) +} + +## low-level: set/clear the rxode2 forced-parameter buffer honored at solve setup +.rxSetForcedParsC <- function(idx, val) { + invisible(.Call(`_rxode2_rxSetForcedPars`, as.integer(idx), as.double(val))) +} +.rxClearForcedParsC <- function() { + invisible(.Call(`_rxode2_rxClearForcedPars`)) +} + +## resolve a ui's forcedPars names to 0-based solve-param indices and load the C +## buffer for the next solve; returns TRUE if anything was set (so the caller +## clears afterward). No-op (and clears) when the model carries no forcedPars. +## `forcedSrc` supplies the values (the ui); `solveModel` supplies the definitive +## solve-parameter order the gpars layout uses (default: forcedSrc itself). +.rxApplyForcedPars <- function(forcedSrc, solveModel = forcedSrc) { + .fp <- tryCatch(forcedSrc$forcedPars, error = function(e) NULL) + if (is.null(.fp) || length(.fp) == 0L) { + .rxClearForcedParsC() + return(FALSE) + } + .pars <- rxModelVars(solveModel)$params + .idx <- match(names(.fp), .pars) - 1L + .keep <- !is.na(.idx) + if (!any(.keep)) { + .rxClearForcedParsC() + return(FALSE) + } + .rxSetForcedParsC(.idx[.keep], unname(.fp)[.keep]) + TRUE +} + .rxSolveGetInit <- function(.env, arg) { .ini <- get(".init.dat", envir = .env, inherits = FALSE) if (arg %in% c("inits", "init")) { diff --git a/man/rxForcedPars.Rd b/man/rxForcedPars.Rd new file mode 100644 index 000000000..1c0db0590 --- /dev/null +++ b/man/rxForcedPars.Rd @@ -0,0 +1,38 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/rxsolve.R +\name{rxForcedPars} +\alias{rxForcedPars} +\alias{rxForcedPars<-} +\title{Forced parameters carried by a model / ui} +\usage{ +rxForcedPars(ui) + +rxForcedPars(ui) <- value +} +\arguments{ +\item{ui}{an rxode2 ui / model function.} + +\item{value}{named numeric vector of forced parameter values, or \code{NULL}.} +} +\value{ +the getter returns the named numeric vector (or \code{NULL}); the setter +returns the (modified) ui. +} +\description{ +Get or set a named-numeric vector of \emph{forced} parameters stored in a hidden +slot of an rxode2 ui. Forced parameters override the values supplied in +\code{params}/data and the model initial estimates on \strong{every} solve of that ui +(they are written into every subject/simulation column at solve setup, the +same injection point used by par-loader hooks). Because the values live on +the ui they travel with it -- through model piping and into any \code{nlmixr2} fit +built from it -- so the model stays self-contained and portable (e.g. a fit +that carries trained neural-network weights re-solves, predicts and simulates +with those weights with no external state). +} +\details{ +Names must be model parameters; names that are not model parameters are +ignored at solve time. Set to \code{NULL} (or an empty vector) to clear. +} +\author{ +Matthew L. Fidler +} diff --git a/man/rxInjectedPars.Rd b/man/rxInjectedPars.Rd new file mode 100644 index 000000000..8a5a84b00 --- /dev/null +++ b/man/rxInjectedPars.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/rxsolve.R +\name{rxInjectedPars} +\alias{rxInjectedPars} +\title{Parameters injected into a solve by a par-loader hook} +\usage{ +rxInjectedPars(obj) +} +\arguments{ +\item{obj}{a solved rxode2 object.} +} +\value{ +a named numeric vector, or \code{NULL} if nothing was injected. +} +\description{ +Returns the parameters (e.g. trained neural-network weights) that a +registered par-loader wrote into the solve parameter vector, saved on the +solved object so re-solving from it restores them. +} diff --git a/src/init.c b/src/init.c index 018923e82..b8b0c90ac 100644 --- a/src/init.c +++ b/src/init.c @@ -760,6 +760,8 @@ SEXP _rxode2_rxSolveSetCurObj_(SEXP); extern SEXP _rxode2_rxRegisterTestParLoaders(SEXP); extern SEXP _rxode2_rxRemoveTestParLoaders(void); extern SEXP _rxode2_rxGetInjectedPars(void); +extern SEXP _rxode2_rxSetForcedPars(SEXP, SEXP); +extern SEXP _rxode2_rxClearForcedPars(void); void R_init_rxode2(DllInfo *info){ allocExtraDosingC(); @@ -767,6 +769,8 @@ void R_init_rxode2(DllInfo *info){ {"_rxode2_rxRegisterTestParLoaders", (DL_FUNC) &_rxode2_rxRegisterTestParLoaders, 1}, {"_rxode2_rxRemoveTestParLoaders", (DL_FUNC) &_rxode2_rxRemoveTestParLoaders, 0}, {"_rxode2_rxGetInjectedPars", (DL_FUNC) &_rxode2_rxGetInjectedPars, 0}, + {"_rxode2_rxSetForcedPars", (DL_FUNC) &_rxode2_rxSetForcedPars, 2}, + {"_rxode2_rxClearForcedPars", (DL_FUNC) &_rxode2_rxClearForcedPars, 0}, {"_rxode2_qsDes", (DL_FUNC) &_rxode2_qsDes, 1}, {"_rxode2_rxGetSerialType_", (DL_FUNC) &_rxode2_rxGetSerialType_, 1}, {"_rxode2_mlogit_f", (DL_FUNC) &_rxode2_mlogit_f, 2}, diff --git a/src/rxData.cpp b/src/rxData.cpp index ef7e471d1..a23dd999c 100644 --- a/src/rxData.cpp +++ b/src/rxData.cpp @@ -4576,12 +4576,47 @@ extern "C" void rxCallDydtForce(int *neq, double t, double *y, double *dydt) { static std::vector _rxInjIdx; static std::vector _rxInjVal; +// Forced parameters for the current solve: (0-based param index, value) pairs set +// from R (a model/ui's `forcedPars` slot resolved to solve-param indices) and +// injected into EVERY gpars column before the registered par-loaders run. This +// is the first-class, plugin-free forcing mechanism: a ui carries its forced +// values (e.g. trained NN weights) and every solve of that ui reproduces them, so +// a fit is self-contained and portable. Population-constant (same value in every +// column), single-threaded (set before the parallel per-subject solve). +static std::vector _rxForcedIdx; +static std::vector _rxForcedVal; + +extern "C" SEXP _rxode2_rxSetForcedPars(SEXP idx, SEXP val) { + int n = Rf_length(idx); + if (Rf_length(val) != n) Rf_error("forcedPars idx/val length mismatch"); + SEXP idxI = PROTECT(Rf_coerceVector(idx, INTSXP)); + SEXP valR = PROTECT(Rf_coerceVector(val, REALSXP)); + _rxForcedIdx.assign(INTEGER(idxI), INTEGER(idxI) + n); + _rxForcedVal.assign(REAL(valR), REAL(valR) + n); + UNPROTECT(2); + return R_NilValue; +} + +extern "C" SEXP _rxode2_rxClearForcedPars(void) { + _rxForcedIdx.clear(); + _rxForcedVal.clear(); + return R_NilValue; +} + static inline void rxCallParLoaders(rx_solve* rx, int npars, int ncols) { _rxInjIdx.clear(); _rxInjVal.clear(); - if (_rxNParLoaders == 0) return; + if (_rxNParLoaders == 0 && _rxForcedIdx.empty()) return; static std::vector pre; pre.assign(&_globals.gpars[0], &_globals.gpars[0] + npars); // subject 0 block + // ui-driven forced parameters: write to every subject/sim column first, so a + // registered loader (if any) can still override them. + for (size_t f = 0; f < _rxForcedIdx.size(); ++f) { + int k = _rxForcedIdx[f]; + if (k < 0 || k >= npars) continue; + double v = _rxForcedVal[f]; + for (int c = 0; c < ncols; ++c) _globals.gpars[(size_t)c * npars + k] = v; + } for (int i = 0; i < _rxNParLoaders; ++i) { _rxParLoaders[i](rx, &_globals.gpars[0], npars, ncols); } diff --git a/tests/testthat/test-forced-pars.R b/tests/testthat/test-forced-pars.R new file mode 100644 index 000000000..4c482aa57 --- /dev/null +++ b/tests/testthat/test-forced-pars.R @@ -0,0 +1,65 @@ +## Per-ui forced parameters: a named-numeric slot carried on the ui whose values +## override params/data/inits on EVERY solve (injected into every gpars column at +## solve setup, the par-loader injection point). This is the plugin-free forcing +## mechanism that lets a model/fit carry externally-owned values (e.g. trained +## neural-network weights) and stay self-contained. + +## slow-decay model: cl = exp(tcl)*WT with tcl small, so cp stays well away from 0 +## across the observed window (a robust, non-saturating response to forcing WT). +.forcedMod <- function() { + ini({ tcl <- -2 }) + model({ + cl <- exp(tcl) * WT + d/dt(depot) <- -cl * depot + cp <- depot + }) +} + +.forcedEv <- function() { + ev <- et(amt = 100) + ev <- et(ev, seq(1, 8, by = 1)) + ev$WT <- 1 + ev +} + +test_that("rxForcedPars getter/setter round-trips and validates", { + ui <- rxode2(.forcedMod) + expect_null(rxForcedPars(ui)) + rxForcedPars(ui) <- c(WT = 2) + expect_equal(rxForcedPars(ui), c(WT = 2)) + ## unnamed -> error + expect_error(`rxForcedPars<-`(ui, 3), "fully-named") + ## clear + rxForcedPars(ui) <- NULL + expect_null(rxForcedPars(ui)) +}) + +test_that("forcedPars overrides the data covariate on rxSolve; unset -> unchanged", { + ui <- rxode2(.forcedMod) + ev <- .forcedEv() + + s0 <- rxSolve(ui, ev) + + ## force WT = 2 -> cl doubles -> faster decay -> strictly lower cp at every t>0 + rxForcedPars(ui) <- c(WT = 2) + s1 <- rxSolve(ui, ev) + expect_true(all(s1$cp < s0$cp)) + + ## the injected value is captured on the solved object too + expect_equal(unname(rxInjectedPars(s1)["WT"]), 2) + + ## unset -> identical to the un-forced solve + rxForcedPars(ui) <- NULL + s2 <- rxSolve(ui, ev) + expect_equal(s2$cp, s0$cp, tolerance = 1e-8) +}) + +test_that("forcedPars names that are not model params are ignored", { + ui <- rxode2(.forcedMod) + ev <- .forcedEv() + s0 <- rxSolve(ui, ev) + ## a bogus name alongside a real one: bogus ignored, WT applied + rxForcedPars(ui) <- c(nonParam = 99, WT = 2) + s1 <- rxSolve(ui, ev) + expect_true(all(s1$cp < s0$cp)) +}) From aea9462e9a12205d1605edc585ee00beb6794f95 Mon Sep 17 00:00:00 2001 From: mattfidler Date: Mon, 13 Jul 2026 23:09:10 -0500 Subject: [PATCH 10/18] Phase 1: document rxForcedPars in the covariates vignette Adds a 'Forced parameters' section to the rxode2 covariates vignette hunk with a runnable rxForcedPars()/rxForcedPars<-() + rxInjectedPars() example. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MmVREEBZmrR1nfv3cFUd39 --- man-roxygen/rmdhunks/covariates.Rmd | 53 +++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/man-roxygen/rmdhunks/covariates.Rmd b/man-roxygen/rmdhunks/covariates.Rmd index 0c375abb5..a1da4df57 100644 --- a/man-roxygen/rmdhunks/covariates.Rmd +++ b/man-roxygen/rmdhunks/covariates.Rmd @@ -164,3 +164,56 @@ plot(r1,C2, ylab="Central Concentration", xlab="Time") ```{r time-varying-nocb-effect} plot(r1,eff, ylab="Effect", xlab="Time") ``` + +# Forced parameters + +Sometimes a parameter's value is owned *outside* the usual `params`/data/`ini` +channel and must be applied on **every** solve of a model -- for example +weights fit by an external optimizer that should travel with the model so it +stays self-contained. `rxForcedPars()` stores such values in a hidden slot on +the model (a named numeric vector). On each solve they are injected into every +subject/simulation column at solve setup, overriding whatever `params` or the +data supplied: + +```{r} +mod <- function() { + ini({ tcl <- -2 }) + model({ + cl <- exp(tcl) * WT + d/dt(depot) <- -cl * depot + cp <- depot + }) +} +ui <- rxode2(mod) + +ev <- et(amt = 100) |> et(seq(1, 8, by = 1)) +ev$WT <- 1 + +## baseline: WT = 1 from the data +s0 <- rxSolve(ui, ev) + +## force WT = 2 for every solve of this model +rxForcedPars(ui) <- c(WT = 2) +s1 <- rxSolve(ui, ev) + +head(data.frame(time = s0$time, cp_WT1 = s0$cp, cp_forcedWT2 = s1$cp)) +``` + +Because the value lives on the model, it is carried through model piping and +into any `nlmixr2` fit built from the model, so `predict()`/`simulate()` on that +fit reuse it with no external state. The forced values are also reported by +`rxInjectedPars()` on the solved object: + +```{r} +rxInjectedPars(s1) +``` + +Clear forcing by assigning `NULL`: + +```{r} +rxForcedPars(ui) <- NULL +``` + +Names that are not model parameters are ignored, and forcing applies only to +population-constant parameters/covariates (a single value per parameter), not +to per-record time-varying covariates. From 181239aa93847ff695340a1995a9f5112e3ec15e Mon Sep 17 00:00:00 2001 From: mattfidler Date: Mon, 13 Jul 2026 23:22:10 -0500 Subject: [PATCH 11/18] Phase 1: make forcedPars a hidden sticky ui slot (not printed) Storing forcedPars in the model meta env printed it with the model. Store it directly on the ui environment instead (funPrint only deparses meta, so it stays hidden) and register 'forcedPars' in the model's sticky vector so it survives model piping (including significant model-block changes, via .getKeepEnv). rxUiCompress/Decompress round-trip preserves it. Test: forcedPars absent from the printed model + carried across a model() pipe (and still applied on the piped model's solve). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MmVREEBZmrR1nfv3cFUd39 --- R/rxsolve.R | 27 ++++++++++++++++++--------- tests/testthat/test-forced-pars.R | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/R/rxsolve.R b/R/rxsolve.R index ac9b10cd6..ecfc81140 100644 --- a/R/rxsolve.R +++ b/R/rxsolve.R @@ -3606,8 +3606,11 @@ rxInjectedPars <- function(obj) { #' that carries trained neural-network weights re-solves, predicts and simulates #' with those weights with no external state). #' -#' Names must be model parameters; names that are not model parameters are -#' ignored at solve time. Set to `NULL` (or an empty vector) to clear. +#' The value is stored directly in the ui environment (not in the printed `meta` +#' block, so it stays hidden) and registered as a `sticky` model item so it +#' survives model piping. Names must be model parameters; names that are not +#' model parameters are ignored at solve time. Set to `NULL` (or an empty +#' vector) to clear. #' #' @param ui an rxode2 ui / model function. #' @param value named numeric vector of forced parameter values, or `NULL`. @@ -3618,9 +3621,8 @@ rxInjectedPars <- function(obj) { rxForcedPars <- function(ui) { .ui <- rxUiDecompress(ui) if (!inherits(.ui, "rxUi")) return(NULL) - .fp <- .ui$forcedPars - if (is.null(.fp)) return(NULL) - .fp + if (!exists("forcedPars", envir = .ui, inherits = FALSE)) return(NULL) + get("forcedPars", envir = .ui, inherits = FALSE) } #' @rdname rxForcedPars @@ -3630,17 +3632,24 @@ rxForcedPars <- function(ui) { if (!inherits(.ui, "rxUi")) { stop("'ui' must be an rxode2 ui/model to set forcedPars", call. = FALSE) } + .sticky <- if (exists("sticky", envir = .ui, inherits = FALSE)) { + get("sticky", envir = .ui, inherits = FALSE) + } else character(0) if (is.null(value) || length(value) == 0L) { - if (exists("forcedPars", envir = .ui$meta, inherits = FALSE)) { - rm("forcedPars", envir = .ui$meta) + if (exists("forcedPars", envir = .ui, inherits = FALSE)) { + rm("forcedPars", envir = .ui) } + assign("sticky", setdiff(.sticky, "forcedPars"), envir = .ui) return(invisible(.ui)) } if (is.null(names(value)) || any(names(value) == "")) { stop("forcedPars must be a fully-named numeric vector", call. = FALSE) } + ## store on the ui env (hidden -- not the printed `meta` block) and mark sticky + ## so it survives model piping. assign("forcedPars", stats::setNames(as.numeric(value), names(value)), - envir = .ui$meta) + envir = .ui) + assign("sticky", unique(c(.sticky, "forcedPars")), envir = .ui) invisible(.ui) } @@ -3658,7 +3667,7 @@ rxForcedPars <- function(ui) { ## `forcedSrc` supplies the values (the ui); `solveModel` supplies the definitive ## solve-parameter order the gpars layout uses (default: forcedSrc itself). .rxApplyForcedPars <- function(forcedSrc, solveModel = forcedSrc) { - .fp <- tryCatch(forcedSrc$forcedPars, error = function(e) NULL) + .fp <- tryCatch(rxForcedPars(forcedSrc), error = function(e) NULL) if (is.null(.fp) || length(.fp) == 0L) { .rxClearForcedParsC() return(FALSE) diff --git a/tests/testthat/test-forced-pars.R b/tests/testthat/test-forced-pars.R index 4c482aa57..109d90930 100644 --- a/tests/testthat/test-forced-pars.R +++ b/tests/testthat/test-forced-pars.R @@ -54,6 +54,28 @@ test_that("forcedPars overrides the data covariate on rxSolve; unset -> unchange expect_equal(s2$cp, s0$cp, tolerance = 1e-8) }) +test_that("forcedPars is hidden from the printed model and survives piping", { + ui <- rxode2(.forcedMod) + rxForcedPars(ui) <- c(WT = 2) + + ## hidden: stored on the ui env, not the printed meta block + .txt <- paste(capture.output(print(ui)), collapse = "\n") + expect_false(grepl("forcedPars", .txt)) + + ## survives a model-block change (a "significant" pipe) via the sticky mechanism + ui2 <- ui |> model(cp <- depot * 1) + expect_equal(rxForcedPars(ui2), c(WT = 2)) + ## still hidden after piping + .txt2 <- paste(capture.output(print(ui2)), collapse = "\n") + expect_false(grepl("forcedPars", .txt2)) + + ## and still applied on the piped model's solve + ev <- .forcedEv() + s0 <- rxSolve(rxode2(.forcedMod), ev) + s2 <- rxSolve(ui2, ev) + expect_true(all(s2$cp < s0$cp)) +}) + test_that("forcedPars names that are not model params are ignored", { ui <- rxode2(.forcedMod) ev <- .forcedEv() From b65adf06451d1b6e227d925de5acc14ede5da7c6 Mon Sep 17 00:00:00 2001 From: mattfidler Date: Tue, 14 Jul 2026 11:33:35 -0500 Subject: [PATCH 12/18] fix(ui): keep IOV etas out of the post-UDF eta refresh The iniDf-based eta refresh (37a94549f) folded occ-level (IOV) etas into the id-level .env$eta list; a mu-referenced 'theta + eta + iov' then parsed as two population etas and failed with 'currently do not theta + eta1 + eta2'. Exclude names already classified in .env$level. Co-Authored-By: Claude Fable 5 --- R/err.R | 5 ++++- tests/testthat/test-occ.R | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/R/err.R b/R/err.R index 31a1cd197..3b220cbee 100644 --- a/R/err.R +++ b/R/err.R @@ -1364,7 +1364,10 @@ rxErrTypeCombine <- function(oldErrType, newErrType) { # the ini({}) omega before those UDFs ran, so refresh it from the now-final # iniDf (the source of truth); otherwise the appended etas are mis-classified # as covariates downstream (mu-referencing / covariate detection). - .etaDf <- .env$df[!is.na(.env$df$neta1), , drop=FALSE] + # Keep non-id (IOV) etas out: they live in .env$level, and including them in + # .env$eta makes a mu-referenced `theta + eta + iov` parse as 2 population etas. + .etaDf <- .env$df[!is.na(.env$df$neta1) & + !(.env$df$name %in% .env$level), , drop=FALSE] if (nrow(.etaDf) > 0L) { .etaDf <- .etaDf[order(.etaDf$neta1), , drop=FALSE] .env$eta <- unique(.etaDf$name) diff --git a/tests/testthat/test-occ.R b/tests/testthat/test-occ.R index ada38a096..225c841a2 100644 --- a/tests/testthat/test-occ.R +++ b/tests/testthat/test-occ.R @@ -383,5 +383,10 @@ rxTest({ expect_equal(curEval[curEval$parameter == "iov.cl", "curEval"], "exp") + # the post-UDF eta refresh must not fold IOV etas into the id-level eta + # list; that makes `theta + eta + iov` parse as 2 population etas and fail + expect_false("iov.cl" %in% f$eta) + expect_true("iov.cl" %in% f$level) + }) }) From 842db8ba1bb7b25cba8ac372fc9b76926f459e55 Mon Sep 17 00:00:00 2001 From: mattfidler Date: Tue, 14 Jul 2026 16:15:46 -0500 Subject: [PATCH 13/18] doc: add "Solve-time hooks for package developers" article New pkgdown article documenting the extension hooks a downstream package uses to change what an rxode2 solve sees: rxForcedPars() (R-level forced parameters carried with the model), the C par-loader hook (rxRegisterParLoader, inject a par_ptr block each solve), the C dydt forcing hook (rxRegisterDydtForce, add a derivative term), and the function-pointer table (.rxode2ptrs()/iniRxodePtrs) that shares rxode2's C entry points cross-package. Companion to the custom-functions article; referenced by nlmixr2est's "Extending nlmixr2est" vignette. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MmVREEBZmrR1nfv3cFUd39 --- vignettes/articles/rxode2-solve-hooks.Rmd | 141 ++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 vignettes/articles/rxode2-solve-hooks.Rmd diff --git a/vignettes/articles/rxode2-solve-hooks.Rmd b/vignettes/articles/rxode2-solve-hooks.Rmd new file mode 100644 index 000000000..4b0c59347 --- /dev/null +++ b/vignettes/articles/rxode2-solve-hooks.Rmd @@ -0,0 +1,141 @@ +--- +title: "Solve-time hooks for package developers" +--- + +```{r, include = FALSE} +knitr::opts_chunk$set( + collapse = TRUE, + comment = "#>", + eval = FALSE +) +``` + +## Overview + +Most `rxode2` models are fully specified by their parameters, data and initial +conditions. Some downstream tools, though, need to change *what a solve sees* from +outside the model text -- for example to inject a block of externally-owned +parameters (neural-network weights, a lookup table) on every solve, or to add a +forcing term to a state derivative. `rxode2` exposes a small set of hooks for this. +They fall into two groups: + +* **R-level:** `rxForcedPars()` -- override parameter values on a model, carried + with the model itself. +* **C-level:** the *par-loader* and *dydt forcing* hooks, plus the + **function-pointer table** that lets a downstream package call `rxode2`'s C + entry points. These are for package authors writing compiled code. + +This article is a companion to +[Providing Custom C/C++ Functions to rxode2](rxode2-pkg-exported-funs.html): that +one adds new *functions* callable inside a model; this one changes the *parameters +and derivatives* a solve uses. + +## `rxForcedPars()`: forced parameters on a model + +`rxForcedPars()` sets a named vector of parameter values that override `params`/data +and the model initial estimates on **every** solve of a ui. The values are stored +on the ui (hidden from the printed model, registered as a `sticky` item), so they +travel with it -- through model piping and into any `nlmixr2` fit built from it. +That keeps a model self-contained: a fit that carries, say, trained weights +re-solves, predicts and simulates with those weights and no external state. + +```{r} +ui <- function() { + model({ + d/dt(center) <- -(cl/v) * center + cp <- center/v + }) +} +ui <- rxode2(ui) +rxForcedPars(ui) <- c(cl = 1.2, v = 8) # force cl, v on every solve +rxForcedPars(ui) # getter +rxForcedPars(ui) <- NULL # clear +``` + +Names that are not model parameters are ignored at solve time. Forced parameters +are written into every subject/simulation column at solve setup -- the *same +injection point* the par-loader hook (below) uses, so a registered loader can still +override them if needed. + +## The par-loader hook (C) + +For a parameter block that is not a fixed vector but is *computed* -- owned by a +plugin and refreshed each solve -- register a **par-loader**. It runs once per +solve, single-threaded, right after `rxode2` lays out the global parameter matrix +(`gpars`) and before the parallel integration, so the injected values are in place +for the (thread-safe) solve. + +```c +/* signature (inst/include/rxode2.h) */ +typedef void (*t_rxParLoader)(rx_solve *rx, double *gpars, int npars, int ncols); +void rxRegisterParLoader(t_rxParLoader cb); +void rxRemoveParLoader(t_rxParLoader cb); +``` + +`gpars` is the flat `ncols x npars` parameter matrix (column `c`'s `par_ptr` is +`&gpars[c*npars]`). A loader writes its block into every column: + +```c +static void myLoader(rx_solve *rx, double *gpars, int npars, int ncols) { + for (int c = 0; c < ncols; ++c) { + double *par_ptr = gpars + (size_t)c * npars; + for (int k = 0; k < BLOCK_N; ++k) par_ptr[base + k] = myBlock[k]; /* base = block start */ + } +} +``` + +Register it in `.onLoad()` (via a `.Call` wrapper) and `rxRemoveParLoader()` in +`.onUnload()`. The block must occupy real `par_ptr` slots: declare its parameters +with `param(...)` (or as covariates) so `rxode2` reserves contiguous positions, +resolve `base` from the model's solve parameter order once, and cache it. +`rxInjectedPars()` reports what a loader changed on the last solve. + +## The dydt forcing hook (C) + +To add a term to a state derivative that the model text cannot express (e.g. a +plugin's contribution computed in C), register a **dydt-force** callback. The +generated model calls it at the end of `dydt`, so the added forcing is integrated +like any other RHS term: + +```c +/* signature (inst/include/rxode2.h) */ +typedef void (*t_rxDydtForce)(int *neq, double t, double *y, double *dydt); +void rxRegisterDydtForce(t_rxDydtForce cb); +void rxRemoveDydtForce(t_rxDydtForce cb); +``` + +```c +static void myForce(int *neq, double t, double *y, double *dydt) { + dydt[stateIdx] += myForcingTerm(t, y); /* on top of the model's own RHS */ +} +``` + +A model with no registered forcing is unaffected (the call is a null-pointer +no-op), so this is safe to leave installed. + +## The function-pointer table + +The hooks above are C callables *inside* `rxode2`. Because CRAN discourages +`R_RegisterCCallable` ABI coupling across packages, `rxode2` shares its C entry +points through a positional **function-pointer table** rather than by name: + +* `rxode2::.rxode2ptrs()` returns the table (an external-pointer list) built by + `_rxode2_rxode2Ptr()` in `src/init.c`. +* A downstream package installs it into its own globals with `iniRxodePtrs()` / + `iniRxodePtrs0()` from the header `inst/include/rxode2ptr.h`, calling + `.Call("_myPkg_iniRxodePtrs", rxode2::.rxode2ptrs())` in `.onLoad()`. + +Each table slot is a fixed index; new entries are appended (so the layout stays +backward compatible). `rxRegisterParLoader` / `rxRegisterDydtForce` are exposed +this way, so a plugin can register its hooks from its own compiled code. See the +`rxode2` source (`src/init.c`, `inst/include/rxode2ptr.h`) for the current table, +and the `nlmixr2nn` package for a worked consumer that uses the par-loader to inject +neural-network weights. + +## See also + +* [Providing Custom C/C++ Functions to rxode2](rxode2-pkg-exported-funs.html) -- + adding model-callable functions. +* `nlmixr2est`: *"Extending nlmixr2est"* -- creating estimation methods, + intercepting estimations, and contributing to the objective, which build on + these solve hooks. From 4ff89dbd98d55c5a7407096cb4cec60c31fa4c24 Mon Sep 17 00:00:00 2001 From: mattfidler Date: Tue, 14 Jul 2026 17:51:20 -0500 Subject: [PATCH 14/18] feat(solve): named par-loaders + per-model injector flag (rxParLoader) Par-loaders were a GLOBAL registry -- every registered loader ran on every solve, so a package's parameter injector (e.g. an nn-weight loader) could overwrite an UNRELATED model's par_ptr just by being registered. Now a loader may be registered NAMED (":") via rxRegisterParLoaderNamed (pointer-table index 86), and runs ONLY while a model flags that name; unnamed loaders keep the legacy always-run behavior. - rxData.cpp: registry stores {name, cb}; rxCallParLoaders skips a named loader unless its name == the active flag (_rxActiveParLoader). New C entry points _rxode2_rxSetActiveParLoader / _rxode2_rxClearActiveParLoader. - R: rxParLoader()/rxParLoader<- store the injector name on the ui (sticky, like rxForcedPars); rxSolve.rxUi bridges it (set active flag before the solve, clear after) alongside .rxApplyForcedPars. - init.c: register the two .Call entries + the named-register pointer (nVec 86->87); rxode2.h + rxode2ptr.h expose rxRegisterParLoaderNamed to downstream packages. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MmVREEBZmrR1nfv3cFUd39 --- NAMESPACE | 2 ++ R/rxsolve.R | 63 ++++++++++++++++++++++++++++++++++++++++ inst/include/rxode2.h | 1 + inst/include/rxode2ptr.h | 7 +++++ src/init.c | 9 +++++- src/rxData.cpp | 47 ++++++++++++++++++++++++++++-- 6 files changed, 125 insertions(+), 4 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index f5e9663b8..ef1d78dc9 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -323,6 +323,7 @@ export("RxODE<-") export("ini<-") export("model<-") export("rxForcedPars<-") +export("rxParLoader<-") export("rxode2<-") export("rxode<-") export(.assertRenameErrorModelLine) @@ -629,6 +630,7 @@ export(rxExpandSens_) export(rxFixPop) export(rxFixRes) export(rxForcedPars) +export(rxParLoader) export(rxForget) export(rxFromSE) export(rxFun) diff --git a/R/rxsolve.R b/R/rxsolve.R index ecfc81140..fd0d9670d 100644 --- a/R/rxsolve.R +++ b/R/rxsolve.R @@ -2195,6 +2195,10 @@ rxSolve.rxUi <- function(object, params = NULL, events = NULL, inits = NULL, ... if (.rxApplyForcedPars(object, .lst[[1L]])) { on.exit(.rxClearForcedParsC(), add = TRUE) } + ## flag this model's parameter injector (if any) so only its loader runs + if (.rxApplyParLoader(object)) { + on.exit(.rxClearActiveParLoaderC(), add = TRUE) + } .ret <- do.call("rxSolve.default", .lst) if (.pred) { .e <- attr(class(.ret), ".rxode2.env") @@ -3683,6 +3687,65 @@ rxForcedPars <- function(ui) { TRUE } +#' Parameter-loader flag on a model +#' +#' A model that needs an externally-owned parameter injector -- a registered +#' par-loader, e.g. a package's neural-network weight loader -- flags that +#' injector's `":"` name here. At solve setup only the loader +#' with that name runs, so an injector never overwrites an unrelated model's +#' `par_ptr` merely because it happens to be registered. The flag is stored on the +#' ui (a sticky item, so it survives model piping) like [rxForcedPars()]; set it to +#' `NULL` to clear. +#' +#' @param ui an rxode2 ui / model function. +#' @param value a single loader name (`":"`), or `NULL`. +#' @return the getter returns the name (or `NULL`); the setter returns the ui. +#' @export +#' @author Matthew L. Fidler +rxParLoader <- function(ui) { + .ui <- rxUiDecompress(ui) + if (!inherits(.ui, "rxUi")) return(NULL) + if (!exists("parLoader", envir = .ui, inherits = FALSE)) return(NULL) + get("parLoader", envir = .ui, inherits = FALSE) +} + +#' @rdname rxParLoader +#' @export +`rxParLoader<-` <- function(ui, value) { + .ui <- rxUiDecompress(ui) + if (!inherits(.ui, "rxUi")) { + stop("'ui' must be an rxode2 ui/model to set rxParLoader", call. = FALSE) + } + .sticky <- if (exists("sticky", envir = .ui, inherits = FALSE)) { + get("sticky", envir = .ui, inherits = FALSE) + } else character(0) + if (is.null(value) || length(value) == 0L || !nzchar(value[1L])) { + if (exists("parLoader", envir = .ui, inherits = FALSE)) rm("parLoader", envir = .ui) + assign("sticky", setdiff(.sticky, "parLoader"), envir = .ui) + return(invisible(.ui)) + } + assign("parLoader", as.character(value[1L]), envir = .ui) + assign("sticky", unique(c(.sticky, "parLoader")), envir = .ui) + invisible(.ui) +} + +## low-level: set/clear the active par-loader name honored at the next solve setup. +.rxSetActiveParLoaderC <- function(name) { + invisible(.Call(`_rxode2_rxSetActiveParLoader`, as.character(name))) +} +.rxClearActiveParLoaderC <- function() { + invisible(.Call(`_rxode2_rxClearActiveParLoader`)) +} + +## bridge: flag the active par-loader from a ui's parLoader item before a solve. +## Returns TRUE if a flag was set (so the caller clears afterward). +.rxApplyParLoader <- function(ui) { + .pl <- tryCatch(rxParLoader(ui), error = function(e) NULL) + if (is.null(.pl) || length(.pl) == 0L || !nzchar(.pl[1L])) return(FALSE) + .rxSetActiveParLoaderC(.pl[1L]) + TRUE +} + .rxSolveGetInit <- function(.env, arg) { .ini <- get(".init.dat", envir = .env, inherits = FALSE) if (arg %in% c("inits", "init")) { diff --git a/inst/include/rxode2.h b/inst/include/rxode2.h index de6ae8835..945e6619e 100644 --- a/inst/include/rxode2.h +++ b/inst/include/rxode2.h @@ -125,6 +125,7 @@ void rxGetSolveAtolRtol(double *atol, double *rtol); // is above, outside the guard). Direct declarations for building rxode2 // itself; downstream packages get these as function pointers via the table. void rxRegisterParLoader(t_rxParLoader cb); +void rxRegisterParLoaderNamed(const char* name, t_rxParLoader cb); void rxRemoveParLoader(t_rxParLoader cb); void rxRegisterDydtForce(t_rxDydtForce cb); void rxRemoveDydtForce(t_rxDydtForce cb); diff --git a/inst/include/rxode2ptr.h b/inst/include/rxode2ptr.h index e703dae50..ce5eeaae2 100644 --- a/inst/include/rxode2ptr.h +++ b/inst/include/rxode2ptr.h @@ -279,6 +279,11 @@ extern "C" { extern rxRegisterParLoader_t rxRegisterParLoader; typedef void (*rxRemoveParLoader_t)(t_rxParLoader cb); extern rxRemoveParLoader_t rxRemoveParLoader; + // Register a NAMED loader (":") that runs only for a model + // flagging that name (rxParLoader()), so an injector never touches an unrelated + // model's par_ptr. + typedef void (*rxRegisterParLoaderNamed_t)(const char* name, t_rxParLoader cb); + extern rxRegisterParLoaderNamed_t rxRegisterParLoaderNamed; // Register / remove a dydt forcing hook (t_rxDydtForce from rxode2.h); the // generated model calls all registered hooks at the end of its RHS so a @@ -377,6 +382,7 @@ extern "C" { rxRemoveParLoader = (rxRemoveParLoader_t) R_ExternalPtrAddrFn(VECTOR_ELT(p, 83)); rxRegisterDydtForce = (rxRegisterDydtForce_t) R_ExternalPtrAddrFn(VECTOR_ELT(p, 84)); rxRemoveDydtForce = (rxRemoveDydtForce_t) R_ExternalPtrAddrFn(VECTOR_ELT(p, 85)); + rxRegisterParLoaderNamed = (rxRegisterParLoaderNamed_t) R_ExternalPtrAddrFn(VECTOR_ELT(p, 86)); } return R_NilValue; } @@ -468,6 +474,7 @@ extern "C" { rxRemoveParLoader_t rxRemoveParLoader = NULL; \ rxRegisterDydtForce_t rxRegisterDydtForce = NULL; \ rxRemoveDydtForce_t rxRemoveDydtForce = NULL; \ + rxRegisterParLoaderNamed_t rxRegisterParLoaderNamed = NULL; \ SEXP iniRxodePtrs(SEXP ptr) { \ return iniRxodePtrs0(ptr); \ } \ diff --git a/src/init.c b/src/init.c index b8b0c90ac..f111cb91d 100644 --- a/src/init.c +++ b/src/init.c @@ -533,8 +533,9 @@ SEXP _rxode2_rxode2Ptr(void) { SEXP rxode2rxRemoveParLoader = PROTECT(R_MakeExternalPtrFn((DL_FUNC)&rxRemoveParLoader, R_NilValue, R_NilValue)); pro++; SEXP rxode2rxRegisterDydtForce = PROTECT(R_MakeExternalPtrFn((DL_FUNC)&rxRegisterDydtForce, R_NilValue, R_NilValue)); pro++; SEXP rxode2rxRemoveDydtForce = PROTECT(R_MakeExternalPtrFn((DL_FUNC)&rxRemoveDydtForce, R_NilValue, R_NilValue)); pro++; + SEXP rxode2rxRegisterParLoaderNamed = PROTECT(R_MakeExternalPtrFn((DL_FUNC)&rxRegisterParLoaderNamed, R_NilValue, R_NilValue)); pro++; -#define nVec 86 +#define nVec 87 SEXP ret = PROTECT(Rf_allocVector(VECSXP, nVec)); pro++; SET_VECTOR_ELT(ret, 0, rxode2rxRmvnSEXP); SET_VECTOR_ELT(ret, 1, rxode2rxParProgress); @@ -622,6 +623,7 @@ SEXP _rxode2_rxode2Ptr(void) { SET_VECTOR_ELT(ret, 83, rxode2rxRemoveParLoader); SET_VECTOR_ELT(ret, 84, rxode2rxRegisterDydtForce); SET_VECTOR_ELT(ret, 85, rxode2rxRemoveDydtForce); + SET_VECTOR_ELT(ret, 86, rxode2rxRegisterParLoaderNamed); SEXP retN = PROTECT(Rf_allocVector(STRSXP, nVec)); pro++; @@ -711,6 +713,7 @@ SEXP _rxode2_rxode2Ptr(void) { SET_STRING_ELT(retN, 83, Rf_mkChar("rxode2rxRemoveParLoader")); SET_STRING_ELT(retN, 84, Rf_mkChar("rxode2rxRegisterDydtForce")); SET_STRING_ELT(retN, 85, Rf_mkChar("rxode2rxRemoveDydtForce")); + SET_STRING_ELT(retN, 86, Rf_mkChar("rxode2rxRegisterParLoaderNamed")); #undef nVec @@ -762,6 +765,8 @@ extern SEXP _rxode2_rxRemoveTestParLoaders(void); extern SEXP _rxode2_rxGetInjectedPars(void); extern SEXP _rxode2_rxSetForcedPars(SEXP, SEXP); extern SEXP _rxode2_rxClearForcedPars(void); +extern SEXP _rxode2_rxSetActiveParLoader(SEXP); +extern SEXP _rxode2_rxClearActiveParLoader(void); void R_init_rxode2(DllInfo *info){ allocExtraDosingC(); @@ -771,6 +776,8 @@ void R_init_rxode2(DllInfo *info){ {"_rxode2_rxGetInjectedPars", (DL_FUNC) &_rxode2_rxGetInjectedPars, 0}, {"_rxode2_rxSetForcedPars", (DL_FUNC) &_rxode2_rxSetForcedPars, 2}, {"_rxode2_rxClearForcedPars", (DL_FUNC) &_rxode2_rxClearForcedPars, 0}, + {"_rxode2_rxSetActiveParLoader", (DL_FUNC) &_rxode2_rxSetActiveParLoader, 1}, + {"_rxode2_rxClearActiveParLoader", (DL_FUNC) &_rxode2_rxClearActiveParLoader, 0}, {"_rxode2_qsDes", (DL_FUNC) &_rxode2_qsDes, 1}, {"_rxode2_rxGetSerialType_", (DL_FUNC) &_rxode2_rxGetSerialType_, 1}, {"_rxode2_mlogit_f", (DL_FUNC) &_rxode2_mlogit_f, 2}, diff --git a/src/rxData.cpp b/src/rxData.cpp index a23dd999c..27c2fb133 100644 --- a/src/rxData.cpp +++ b/src/rxData.cpp @@ -4515,24 +4515,61 @@ static inline void rxSolve_assignGpars(rxSolve_t* rxSolveDat); typedef void (*t_rxParLoader)(rx_solve* rx, double* gpars, int npars, int ncols); #define RX_MAX_PAR_LOADERS 32 static t_rxParLoader _rxParLoaders[RX_MAX_PAR_LOADERS] = {NULL}; +// Each loader has a NAME (empty = "unnamed"). A model that needs a specific +// injector flags its name (via rxParLoader() -> _rxActiveParLoader, below); a +// NAMED loader then runs ONLY for a model that flags it, so an injector (e.g. a +// package's neural-network weight loader) cannot clobber an unrelated model's +// par_ptr just because it happens to be registered. Unnamed loaders keep the +// legacy "run on every solve" behavior for backward compatibility. +static std::string _rxParLoaderNames[RX_MAX_PAR_LOADERS]; static int _rxNParLoaders = 0; +static std::string _rxActiveParLoader; // set per-solve; empty = no named loader -extern "C" void rxRegisterParLoader(t_rxParLoader cb) { +static void rxRegisterParLoaderImpl(const char* name, t_rxParLoader cb) { if (cb == NULL) return; for (int i = 0; i < _rxNParLoaders; ++i) if (_rxParLoaders[i] == cb) return; - if (_rxNParLoaders < RX_MAX_PAR_LOADERS) _rxParLoaders[_rxNParLoaders++] = cb; + if (_rxNParLoaders < RX_MAX_PAR_LOADERS) { + _rxParLoaderNames[_rxNParLoaders] = (name == NULL) ? std::string() : std::string(name); + _rxParLoaders[_rxNParLoaders++] = cb; + } +} + +extern "C" void rxRegisterParLoader(t_rxParLoader cb) { + rxRegisterParLoaderImpl(NULL, cb); // unnamed -> legacy always-run +} + +// Register a loader under a ":" name so it dispatches only to +// models that flag that name. +extern "C" void rxRegisterParLoaderNamed(const char* name, t_rxParLoader cb) { + rxRegisterParLoaderImpl(name, cb); } extern "C" void rxRemoveParLoader(t_rxParLoader cb) { for (int i = 0; i < _rxNParLoaders; ++i) { if (_rxParLoaders[i] == cb) { - for (int k = i; k < _rxNParLoaders - 1; ++k) _rxParLoaders[k] = _rxParLoaders[k + 1]; + for (int k = i; k < _rxNParLoaders - 1; ++k) { + _rxParLoaders[k] = _rxParLoaders[k + 1]; + _rxParLoaderNames[k] = _rxParLoaderNames[k + 1]; + } _rxParLoaders[--_rxNParLoaders] = NULL; + _rxParLoaderNames[_rxNParLoaders].clear(); return; } } } +// The active injector flag for the next solve (set from the model that is about +// to be solved), consumed by rxCallParLoaders and then cleared. +extern "C" SEXP _rxode2_rxSetActiveParLoader(SEXP nameSxp) { + _rxActiveParLoader = (TYPEOF(nameSxp) == STRSXP && Rf_length(nameSxp) >= 1) ? + std::string(CHAR(STRING_ELT(nameSxp, 0))) : std::string(); + return R_NilValue; +} +extern "C" SEXP _rxode2_rxClearActiveParLoader(void) { + _rxActiveParLoader.clear(); + return R_NilValue; +} + // ---- dydt forcing hooks --------------------------------------------------- // Packages register a callback the generated model invokes at the end of its RHS // (dydt) evaluation, so they can ADD forcing to designated state derivatives -- @@ -4618,6 +4655,10 @@ static inline void rxCallParLoaders(rx_solve* rx, int npars, int ncols) { for (int c = 0; c < ncols; ++c) _globals.gpars[(size_t)c * npars + k] = v; } for (int i = 0; i < _rxNParLoaders; ++i) { + // an UNNAMED loader runs always (legacy); a NAMED loader runs only when the + // model being solved flags its name (_rxActiveParLoader), so a package's + // injector never touches an unrelated model's par_ptr. + if (!_rxParLoaderNames[i].empty() && _rxParLoaderNames[i] != _rxActiveParLoader) continue; _rxParLoaders[i](rx, &_globals.gpars[0], npars, ncols); } for (int k = 0; k < npars; ++k) { From 4be3a4aba22172a1bee7e6936a647936668a3d24 Mon Sep 17 00:00:00 2001 From: mattfidler Date: Tue, 14 Jul 2026 17:52:18 -0500 Subject: [PATCH 15/18] doc: document named par-loaders + rxParLoader flag in the solve-hooks article Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MmVREEBZmrR1nfv3cFUd39 --- vignettes/articles/rxode2-solve-hooks.Rmd | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/vignettes/articles/rxode2-solve-hooks.Rmd b/vignettes/articles/rxode2-solve-hooks.Rmd index 4b0c59347..520b199fe 100644 --- a/vignettes/articles/rxode2-solve-hooks.Rmd +++ b/vignettes/articles/rxode2-solve-hooks.Rmd @@ -90,6 +90,29 @@ with `param(...)` (or as covariates) so `rxode2` reserves contiguous positions, resolve `base` from the model's solve parameter order once, and cache it. `rxInjectedPars()` reports what a loader changed on the last solve. +### Named loaders: dispatch to the right model only + +A loader registered with `rxRegisterParLoader()` runs on **every** solve, so it must +guard itself, and it can still corrupt an unrelated model if its guards are loose. +Prefer a **named** loader instead: + +```c +rxRegisterParLoaderNamed("myPkg:myLoader", myLoader); /* runs only when flagged */ +``` + +A named loader runs *only* while a model flags its name. Flag the injector on the +model that needs it with `rxParLoader()` (a sticky ui item, like `rxForcedPars()`): + +```{r} +rxParLoader(ui) <- "myPkg:myLoader" # only this loader fires for ui's solves +``` + +`rxSolve()` sets that flag active for the model's solve and clears it afterward, so +the loader never touches any other model. For solves that bypass the ui path +(estimation internals, plain `rxode2()` models), a package can set the active flag +directly around its own solve batch and clear it after. This is how `nlmixr2nn` +keeps its neural-network weight loader from leaking into unrelated fits. + ## The dydt forcing hook (C) To add a term to a state derivative that the model text cannot express (e.g. a From 214b6e40211a25d762d2fdb8991ca68db241fc3f Mon Sep 17 00:00:00 2001 From: mattfidler Date: Tue, 14 Jul 2026 23:52:35 -0500 Subject: [PATCH 16/18] review: address Copilot feedback on the solve-hooks PR - err.R: make the post-UDF eta refresh authoritative -- clear .env$eta (to NULL, matching the init) when only IOV/no etas remain, so a stale omega-derived name cannot leak. - rxsolve.R: .rxApplyInjectedPars now restores injected params onto a parameter data.frame or matrix (not just a single named vector) and falls back to the solved object's stored .params.dat when there is no single named vector. - rxForcedPars<-: reject non-numeric input up front instead of letting as.numeric() silently coerce characters/factors to NA. - rxData.cpp: warn when the par-loader registry is full (registration usually runs in .onLoad, so a dropped loader must be diagnosable); correct the active-flag comment to describe the real lifecycle (cleared by the R on-exit hook, not by rxCallParLoaders). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MmVREEBZmrR1nfv3cFUd39 --- R/err.R | 4 ++++ R/rxsolve.R | 30 ++++++++++++++++++++++++++++-- src/rxData.cpp | 11 +++++++++-- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/R/err.R b/R/err.R index 3b220cbee..eed8cfafa 100644 --- a/R/err.R +++ b/R/err.R @@ -1371,6 +1371,10 @@ rxErrTypeCombine <- function(oldErrType, newErrType) { if (nrow(.etaDf) > 0L) { .etaDf <- .etaDf[order(.etaDf$neta1), , drop=FALSE] .env$eta <- unique(.etaDf$name) + } else { + # only IOV (or no) etas remain -> no population etas; clear the list so a + # stale omega-derived name cannot leak (matches the NULL init above). + .env$eta <- NULL } if (is.null(.env$predDf)) { ## .env$errGlobal <- c(.env$errGlobal, diff --git a/R/rxsolve.R b/R/rxsolve.R index fd0d9670d..e40c37353 100644 --- a/R/rxsolve.R +++ b/R/rxsolve.R @@ -3370,11 +3370,32 @@ rxSolve.rxSolve <- function(object, params = NULL, events = NULL, inits = NULL, ## Restore par-loader-injected parameters (e.g. trained NN weights) onto the ## params for a re-solve, so re-solving from a solved object reproduces them -## even without the injecting package. Injected values override for their -## names (and are appended if absent); only applies to a single named vector. +## even without the injecting package. Injected values override for their names +## (and are appended if absent). Handles a single named vector, a parameter +## data.frame, or a matrix; when no params are supplied it falls back to the +## solved object's stored parameter data.frame (multi-subject/multi-sim solves +## have no single named vector). .rxApplyInjectedPars <- function(params, object) { .inj <- rxInjectedPars(object) if (is.null(.inj) || length(.inj) == 0L) return(params) + if (is.null(params)) params <- object$.params.dat + if (is.null(params)) return(params) + if (is.data.frame(params)) { + for (.n in names(.inj)) params[[.n]] <- .inj[[.n]] + return(params) + } + if (is.matrix(params)) { + for (.n in names(.inj)) { + if (!is.null(colnames(params)) && .n %in% colnames(params)) { + params[, .n] <- .inj[[.n]] + } else { + params <- cbind(params, + matrix(.inj[[.n]], nrow = nrow(params), ncol = 1L, + dimnames = list(NULL, .n))) + } + } + return(params) + } if (is.numeric(params) && is.null(dim(params)) && !is.null(names(params))) { params[names(.inj)] <- .inj } @@ -3649,6 +3670,11 @@ rxForcedPars <- function(ui) { if (is.null(names(value)) || any(names(value) == "")) { stop("forcedPars must be a fully-named numeric vector", call. = FALSE) } + # reject non-numeric input up front; as.numeric() would otherwise coerce + # characters/factors to NA and silently force parameters to NA_real_. + if (!is.numeric(value)) { + stop("forcedPars must be numeric (got '", class(value)[1], "')", call. = FALSE) + } ## store on the ui env (hidden -- not the printed `meta` block) and mark sticky ## so it survives model piping. assign("forcedPars", stats::setNames(as.numeric(value), names(value)), diff --git a/src/rxData.cpp b/src/rxData.cpp index 27c2fb133..a1c5e8894 100644 --- a/src/rxData.cpp +++ b/src/rxData.cpp @@ -4531,6 +4531,11 @@ static void rxRegisterParLoaderImpl(const char* name, t_rxParLoader cb) { if (_rxNParLoaders < RX_MAX_PAR_LOADERS) { _rxParLoaderNames[_rxNParLoaders] = (name == NULL) ? std::string() : std::string(name); _rxParLoaders[_rxNParLoaders++] = cb; + } else { + // registration usually happens during a package's .onLoad(); fail loudly so a + // dropped injector is diagnosable instead of silently never running. + Rf_warning("rxode2: parameter-loader registry full (max %d); loader '%s' not registered", + RX_MAX_PAR_LOADERS, (name == NULL) ? "" : name); } } @@ -4558,8 +4563,10 @@ extern "C" void rxRemoveParLoader(t_rxParLoader cb) { } } -// The active injector flag for the next solve (set from the model that is about -// to be solved), consumed by rxCallParLoaders and then cleared. +// The active injector flag for the next solve: set from the model about to be +// solved (rxSetActiveParLoader), READ by rxCallParLoaders to dispatch to the +// matching named loader, and cleared afterward by the R on-exit hook +// .rxClearActiveParLoaderC() -- rxCallParLoaders does not clear it itself. extern "C" SEXP _rxode2_rxSetActiveParLoader(SEXP nameSxp) { _rxActiveParLoader = (TYPEOF(nameSxp) == STRSXP && Rf_length(nameSxp) >= 1) ? std::string(CHAR(STRING_ELT(nameSxp, 0))) : std::string(); From 78aa898eda1f020a881c74c89fc5720c1397a66b Mon Sep 17 00:00:00 2001 From: mattfidler Date: Tue, 14 Jul 2026 23:56:35 -0500 Subject: [PATCH 17/18] feat(solve): ui-prep hooks -- rehydrate transient C state before a ui solve rxRegisterUiPrep(name, fn) / rxRemoveUiPrep(name) register functions rxode2 calls with the ui at the start of rxSolve.rxUi (before parameter loaders run). A package uses this to rebuild transient C-side state from serializable ui slots after a ui has been saved to disk and reloaded in a fresh session -- e.g. re-registering neural-network shapes so the weight values already carried in rxForcedPars() land in a network that can stride them. Hook errors are downgraded to a warning so a buggy plugin cannot break unrelated solves. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MmVREEBZmrR1nfv3cFUd39 --- NAMESPACE | 4 ++- R/rxsolve.R | 60 +++++++++++++++++++++++++++++++++++++++++ man/rxForcedPars.Rd | 7 +++-- man/rxParLoader.Rd | 31 +++++++++++++++++++++ man/rxRegisterUiPrep.Rd | 28 +++++++++++++++++++ 5 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 man/rxParLoader.Rd create mode 100644 man/rxRegisterUiPrep.Rd diff --git a/NAMESPACE b/NAMESPACE index ef1d78dc9..5833b25d6 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -630,7 +630,6 @@ export(rxExpandSens_) export(rxFixPop) export(rxFixRes) export(rxForcedPars) -export(rxParLoader) export(rxForget) export(rxFromSE) export(rxFun) @@ -679,6 +678,7 @@ export(rxOldQsDes) export(rxOmegaVarCovDeriv) export(rxOmegaVarCovDeriv_) export(rxOptExpr) +export(rxParLoader) export(rxParam) export(rxParams) export(rxParseErr) @@ -695,8 +695,10 @@ export(rxProgressStop) export(rxPrune) export(rxRateDur) export(rxRawToC) +export(rxRegisterUiPrep) export(rxReload) export(rxRemoveControl) +export(rxRemoveUiPrep) export(rxRename) export(rxRepR0_) export(rxReq) diff --git a/R/rxsolve.R b/R/rxsolve.R index e40c37353..475a37f36 100644 --- a/R/rxsolve.R +++ b/R/rxsolve.R @@ -2190,6 +2190,9 @@ rxSolve.rxUi <- function(object, params = NULL, events = NULL, inits = NULL, ... .lst$drop <- c(.lst$drop, "ipredSim") } } + ## let packages rehydrate transient C state from the ui's serializable slots + ## (e.g. NN shapes) before parameters are loaded -- keeps a reloaded ui solvable. + .rxRunUiPrepHooks(object) ## forced (externally-owned) parameters carried on the ui -- injected into every ## solve column at setup (rxCallParLoaders), overriding params/data/inits. if (.rxApplyForcedPars(object, .lst[[1L]])) { @@ -3772,6 +3775,63 @@ rxParLoader <- function(ui) { TRUE } +## ---- ui-solve preparation hooks ------------------------------------------- +## Registry of functions called with the ui at the start of a ui solve (before +## parameter loaders run). A package uses this to rehydrate transient C-side +## state from serializable ui slots after a ui has been saved to disk and +## reloaded in a fresh session (e.g. re-registering neural-network shapes so the +## weight values carried in `rxForcedPars()` land in a network that can stride +## them). Each hook must be cheap and a no-op for models it does not own. +.rxUiPrepHooks <- new.env(parent = emptyenv()) + +#' Register or remove a solve-time ui preparation hook +#' +#' Package developers register a function called with the ui at the start of a +#' ui solve, before parameter loaders run. Use it to rehydrate transient C-side +#' state from serializable ui slots (so a ui saved to disk and reloaded in a +#' fresh session solves correctly). The hook must be cheap and a no-op for +#' models it does not own. +#' +#' @param name Unique hook name; re-registering the same name replaces it. +#' @param fn Function of one argument (the rxUi being solved). Return value is +#' ignored; errors are downgraded to a warning so a buggy hook cannot break +#' unrelated solves. +#' @return Invisibly, the hook name (register) or `NULL` (remove). +#' @export +rxRegisterUiPrep <- function(name, fn) { + if (!is.character(name) || length(name) != 1L) { + stop("'name' must be a single string", call. = FALSE) + } + if (!is.function(fn)) { + stop("'fn' must be a function of one argument (the ui)", call. = FALSE) + } + assign(name, fn, envir = .rxUiPrepHooks) + invisible(name) +} + +#' @rdname rxRegisterUiPrep +#' @export +rxRemoveUiPrep <- function(name) { + if (exists(name, envir = .rxUiPrepHooks, inherits = FALSE)) { + rm(list = name, envir = .rxUiPrepHooks) + } + invisible(NULL) +} + +.rxRunUiPrepHooks <- function(object) { + .nm <- ls(envir = .rxUiPrepHooks, all.names = TRUE) + if (length(.nm) == 0L) return(invisible()) + for (.n in .nm) { + .fn <- get(.n, envir = .rxUiPrepHooks) + tryCatch(.fn(object), + error = function(e) { + warning("rxode2 ui-prep hook '", .n, "' failed: ", + conditionMessage(e), call. = FALSE) + }) + } + invisible() +} + .rxSolveGetInit <- function(.env, arg) { .ini <- get(".init.dat", envir = .env, inherits = FALSE) if (arg %in% c("inits", "init")) { diff --git a/man/rxForcedPars.Rd b/man/rxForcedPars.Rd index 1c0db0590..e6800392f 100644 --- a/man/rxForcedPars.Rd +++ b/man/rxForcedPars.Rd @@ -30,8 +30,11 @@ that carries trained neural-network weights re-solves, predicts and simulates with those weights with no external state). } \details{ -Names must be model parameters; names that are not model parameters are -ignored at solve time. Set to \code{NULL} (or an empty vector) to clear. +The value is stored directly in the ui environment (not in the printed \code{meta} +block, so it stays hidden) and registered as a \code{sticky} model item so it +survives model piping. Names must be model parameters; names that are not +model parameters are ignored at solve time. Set to \code{NULL} (or an empty +vector) to clear. } \author{ Matthew L. Fidler diff --git a/man/rxParLoader.Rd b/man/rxParLoader.Rd new file mode 100644 index 000000000..b774821da --- /dev/null +++ b/man/rxParLoader.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/rxsolve.R +\name{rxParLoader} +\alias{rxParLoader} +\alias{rxParLoader<-} +\title{Parameter-loader flag on a model} +\usage{ +rxParLoader(ui) + +rxParLoader(ui) <- value +} +\arguments{ +\item{ui}{an rxode2 ui / model function.} + +\item{value}{a single loader name (\code{":"}), or \code{NULL}.} +} +\value{ +the getter returns the name (or \code{NULL}); the setter returns the ui. +} +\description{ +A model that needs an externally-owned parameter injector -- a registered +par-loader, e.g. a package's neural-network weight loader -- flags that +injector's \code{":"} name here. At solve setup only the loader +with that name runs, so an injector never overwrites an unrelated model's +\code{par_ptr} merely because it happens to be registered. The flag is stored on the +ui (a sticky item, so it survives model piping) like \code{\link[=rxForcedPars]{rxForcedPars()}}; set it to +\code{NULL} to clear. +} +\author{ +Matthew L. Fidler +} diff --git a/man/rxRegisterUiPrep.Rd b/man/rxRegisterUiPrep.Rd new file mode 100644 index 000000000..e9908ff0a --- /dev/null +++ b/man/rxRegisterUiPrep.Rd @@ -0,0 +1,28 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/rxsolve.R +\name{rxRegisterUiPrep} +\alias{rxRegisterUiPrep} +\alias{rxRemoveUiPrep} +\title{Register or remove a solve-time ui preparation hook} +\usage{ +rxRegisterUiPrep(name, fn) + +rxRemoveUiPrep(name) +} +\arguments{ +\item{name}{Unique hook name; re-registering the same name replaces it.} + +\item{fn}{Function of one argument (the rxUi being solved). Return value is +ignored; errors are downgraded to a warning so a buggy hook cannot break +unrelated solves.} +} +\value{ +Invisibly, the hook name (register) or \code{NULL} (remove). +} +\description{ +Package developers register a function called with the ui at the start of a +ui solve, before parameter loaders run. Use it to rehydrate transient C-side +state from serializable ui slots (so a ui saved to disk and reloaded in a +fresh session solves correctly). The hook must be cheap and a no-op for +models it does not own. +} From 70359340b0913f36e047f0a52f38c1e7c77a9dbc Mon Sep 17 00:00:00 2001 From: mattfidler Date: Wed, 15 Jul 2026 00:21:43 -0500 Subject: [PATCH 18/18] doc: document rxRegisterUiPrep in the solve-hooks article Explain the ui-prep hook as the reload-safety companion to the C par-loader: how to rehydrate transient C state from serializable ui slots, resolve positions by name, and the nlmixr2nn weight-persistence pattern (weights in rxForcedPars, shapes in a sticky nnMeta slot re-registered by the prep hook). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01MmVREEBZmrR1nfv3cFUd39 --- vignettes/articles/rxode2-solve-hooks.Rmd | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/vignettes/articles/rxode2-solve-hooks.Rmd b/vignettes/articles/rxode2-solve-hooks.Rmd index 520b199fe..92bfb7f23 100644 --- a/vignettes/articles/rxode2-solve-hooks.Rmd +++ b/vignettes/articles/rxode2-solve-hooks.Rmd @@ -136,6 +136,39 @@ static void myForce(int *neq, double t, double *y, double *dydt) { A model with no registered forcing is unaffected (the call is a null-pointer no-op), so this is safe to leave installed. +## `rxRegisterUiPrep()`: rehydrating state before a ui solve + +The C hooks above rely on state a package registers *in the running session*. +That state is lost when a model or fit is saved to disk and reloaded in a fresh +session -- the serializable ui survives, but the C-side registry behind it does +not. `rxRegisterUiPrep(name, fn)` closes that gap: `fn(ui)` is called with the +ui at the very start of every ui solve (before parameter loaders run), so a +package can rebuild transient C state from serializable ui slots. + +```r +# in a downstream package's .onLoad(): +rxode2::rxRegisterUiPrep("myPkg:rehydrate", function(ui) { + meta <- ui$myMeta # a slot the package stamped on the ui at fit time + if (is.null(meta)) return(invisible()) # no-op for models it does not own + # ... resolve indices BY NAME from the current parameter layout and re-register + # the C-side state so the reloaded ui solves correctly ... +}) +``` + +Guidelines for a prep hook: + +* **Be cheap and a no-op for unrelated models** -- it runs on *every* ui solve. +* **Resolve positions by name, not by a stored index.** A ui saved by one build + and reloaded by another may have a different parameter column layout; matching + a saved *name* against the current layout is robust, a cached integer is not. +* Errors are downgraded to a warning so a buggy hook cannot break other solves. + +This is how `nlmixr2nn` makes a saved neural-network fit reload: the trained +weights ride on the ui in `rxForcedPars()` and the network *shapes* in a sticky +`nnMeta` slot; the prep hook re-registers the shapes (base resolved by name) so +the persisted weight values land in a network that can stride them. Pair a +`rxRegisterUiPrep()` with `rxRemoveUiPrep(name)` in `.onUnload()`. + ## The function-pointer table The hooks above are C callables *inside* `rxode2`. Because CRAN discourages