Repository navigation
Conversation
…one-method Runner trait Replace the 20-method NonParametricRunner trait with: - FitState: the state every algorithm shares (problem, theta, psi, weights, log-likelihood, cycle, status, cycle log), plus the shared steps (estimate, condense, zero-probability check, stop limits). - Runner: a single step(&mut self, &mut FitState) -> Result<Status>. Each algorithm keeps only its own state (NPAG: grid eps and convergence bookkeeping; NPOD: nothing beyond config). - Fit: owns the cycle loop, tracing span, cycle log and stop file, and consumes itself into the result instead of cloning everything. The error-model optimizer now owns its adaptive steps and works on FitState. A single blanket Algorithm impl replaces the per-config impls. NPAG and NPOD results are bit-identical. NPMAP and NCNPAG now report objf as -2LL (they reported the log-likelihood), count their single pass as cycle 1, and log it on the controller path too.
Contributor
|
| Project | PMcore |
| Branch | refactor/algorithm-trait |
| Testbed | mhovd-pgx |
Click to view all benchmark results
| Benchmark | Latency | Benchmark Result milliseconds (ms) (Result Δ%) | Upper Boundary milliseconds (ms) (Limit %) |
|---|---|---|---|
| bimodal_ke_npag | 📈 view plot 🚷 view threshold | 3,525.10 ms(-18.59%)Baseline: 4,330.08 ms | 6,428.47 ms (54.84%) |
| bimodal_ke_npod | 📈 view plot 🚷 view threshold | 801.45 ms(-38.39%)Baseline: 1,300.76 ms | 2,053.72 ms (39.02%) |
| bimodal_ke_postprob | 📈 view plot 🚷 view threshold | 345.02 ms(-11.30%)Baseline: 388.99 ms | 831.47 ms (41.50%) |
- Solution bundles theta, psi, weights and log-likelihood. It is built only
by solving for optimal weights or from given weights, and is replaced as a
whole. The error-model optimizer's long-standing habit of keeping stale
weights is now a single, named call (with_stale_weights).
- FitState fields are private. Runners change the solution and error models
only through set_solution/adopt_error_models; cycle bookkeeping belongs to
Fit.
- Runners return CycleOutcome::{Continue, Converged}. Fit applies the cycle
cap and stop file and logs the cycle summary, so no runner can forget them.
- Fit::new has no side effects. Stop-file watching (and removal of a stale
file) is opted into by the public entry points, so NCNPAG's nested NPAG
fits no longer delete or react to it.
- NpodConfig exposes objective_tolerance, min_distance, prune_threshold and
qr_tolerance (defaults unchanged; serde(default) keeps old configs loading).
- FitController gains weights(), log_likelihood() and objf(); likelihood()
and n2ll() are deprecated aliases.
NPAG, NPOD, NPMAP and NCNPAG results are bit-identical to the previous
commit.
mhovd
force-pushed
the
refactor/algorithm-trait
branch
from
October 29, 2026 13:26
e446d1a to
56dc862
Compare
- A stopped fit stays stopped: FitController::step does nothing once the
fit has stopped, and request_stop no longer gets overwritten by the next
step (the docs promised this; the code didn't do it). A fit that already
stopped keeps its reason.
- NonParametricResult gains status() and stop_reason().
- FitController::run_until(|fit| -> bool) replaces fit_with_observer,
FitObserver and CycleFlow, which are removed.
- FitController's deprecated likelihood() and n2ll() are removed; use
log_likelihood() and objf().
- NonParametricResult::get_theta() is renamed to theta().
- predictions, write_predictions, write_json and write_outputs take a
PredictionGrid instead of two positional floats (idelta, tad).
NPPredictions::calculate takes it too and no longer copies the data.
- Subjects with zero probability now fail with a typed
ZeroProbabilityError that names them (recover it with downcast_ref).
It is raised where the condition actually occurs, in calculate_psi, and
the per-subject likelihood diagnostics now run on that path.
- Docs and examples use the config structs (NpagConfig::new()) to pick an
algorithm; NonParametricAlgorithm is documented for runtime selection.
BREAKING CHANGE: fit_with_observer, FitObserver, CycleFlow,
FitController::{likelihood, n2ll} and NonParametricResult::get_theta are
removed; result prediction/output methods take a PredictionGrid.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #333 (
chore/pharmsol-main). Retarget tomainonce that merges. Until then CI won't run, becauserust.ymlonly triggers for PRs intomain.Why
NonParametricRunnerhad ~20 methods. Most were accessors (theta,psi,cycle,set_status,log_cycle_state,into_result, …) that each of the 4 algorithms reimplemented almost identically. NPMAP and NCNPAG stubbed outcondensation,optimizationsandexpansion, then overrodefitto skip the cycle loop. Because the controller path didn't use that override, the two paths behaved differently. Nothing kept θ, Ψ and the weights in agreement, and every algorithm had to remember the cycle cap, the stop file and logging on its own.Design
An NPAG cycle now reads top to bottom:
Other changes:
FitState::condense.ErrorModelOptimizerowns its adaptive steps and works on candidateSolutions. It previously took 9&mutarguments.Solution::with_stale_weights, documented as the one deliberate break of the invariant (see the open question below).Algorithmimpls: one blanketimpl<A: Into<NonParametricAlgorithm>> Algorithm<E, NonParametric> for Areplaces the 5 hand-written impls. The parametric side gets the same treatment.unwrap(). Errors are now propagated or logged instead.Behaviour changes
NPAG and NPOD results are bit-identical to the base branch, including with error-model optimisation active (see Verification).
NPMAP and NCNPAG change:
objfis now −2LL. It used to be the log-likelihood (andcycles.csvlabelled itneg2ll).cycles() == 1, not 0.tests/onecomp.rsis updated for this.fit_controller(NpmapConfig)now writes the cycle log entry. It used to be empty on that path.Stop file:
stopfile is still honoured byfit_withandfit_controller, and a stale one is still removed at start. This is now documented on thenonparametricmodule.Fit::newitself no longer touches the filesystem. The public entry points opt in withwatch_stop_file().stopcreated during NCNPAG stopped at most one point's refinement.Minor:
NPCycle::delta_objfisinfinstead of ~1e30.API changes
NonParametricRunnerand the public structsNPAG,NPOD,NPMAPandNCNPAG. Their constructors werepub(crate), so nothing outside the crate could build one.NpodConfiggainsobjective_tolerance,min_distance,prune_thresholdandqr_tolerance, with setters. These were hard-coded constants; the defaults are unchanged.#[serde(default)]keeps older serialised configs loading, and a test covers this.FitControllergainsweights(),log_likelihood()andobjf(); before this there was no way to read the weights while a fit ran.likelihood()andn2ll()are removed.objfis now the name everywhere in the Rust API (result, cycle log, controller,FitResult) and always means −2LL. Theneg2llCSV column is unchanged.FitController<E>andfit_controllergo fromE: Equation + Send + 'statictoE: Equation. That is no looser in practice, sinceEquation: Clone + Send + Sync + 'static.Public API (third commit,
feat!)These changes are breaking, and nothing is kept as a deprecated alias.
FitController::stepused to run another full cycle even after the fit had stopped, despite its docs saying further steps changed nothing. It also overwrote arequest_stop(), which is why the docs said to callrequest_stop"just beforeinto_result". Now:stepdoes nothing and returns the final status;request_stopis final;NonParametricResult::status()andstop_reason(). TellingMaxCycles,StopFileandAbortedapart no longer needs the cycle log.FitController::run_until(|fit| -> bool)replacesfit_with_observer.FitObserverandCycleFloware removed. The closure runs after every cycle, the last one included, so it also works for progress reporting:get_theta()→theta().PredictionGridreplaces the positionalidelta, tadfloats inpredictions,write_predictions,write_json,write_outputsandNPPredictions::calculate:NPPredictions::calculatealso stops cloning the whole dataset before expanding it.ZeroProbabilityError. Subjects with zero probability now fail with a typed error that names them, which you can recover witherr.downcast_ref::<ZeroProbabilityError>():calculate_psi. It used to surface there as a generic "Each subject must have at least one finite log-likelihood" without naming anyone, and the per-subject likelihood diagnostics inFitStatewere effectively unreachable.NpagConfig::new()).NonParametricAlgorithmis documented for choosing at runtime.Not changed on purpose:
EstimationProblem::nonparametric(equation, data, prior, error_models). All four arguments have distinct types, so the compiler already rejects a mix-up. A builder would replace that with a runtime "missing prior" error.NonParametricAlgorithm::npag()etc. These are kept, but nothing in the repo uses them any more.Verification
bimodal_kebefore and after each commit: NPAG and NPOD, each with λ = 0 and λ = 2 (error-model optimisation active), NPMAP, NCNPAG with 50 and 0 cycles, 20 controller-stepped NPAG cycles, the controller NPMAP path, and a callback that stops NPOD after 5 cycles. Cycles, −2LL, nspp, weights, θ, cycle log and error-model factors are bit-identical. The only exceptions are the NPMAP/NCNPAG changes in the first commit; theirobjfis exactly −2× the old value. The last commit is bit-identical to the one before it for all four algorithms.cargo test --all-features: 95 passed. The new tests cover:objf= −2LL;Solution::optimalagreeing with the marginal likelihood;NpodConfigJSON still loading;request_stopkeeping an earlier reason;run_until(every cycle seen; both stop paths);ZeroProbabilityErrordetection and downcasting.cargo clippy --all-targets --all-featuresreports 1 warning, which predates this branch. The other old one, a redundant+ Syncbound, is fixed.bimodal_kegives the same result as chore: use pharmsol from main #333 (320 cycles, −2LL −429.2039). Its five output files (theta,posterior,pred,covs,cycles) are byte-identical before and after the third commit.theophylline,two_eq_lag,neely,chain_algorithmsandbestdose(with--features dsl) all run cleanly.run_untilwith the same stopping rule.Performance
An earlier version of this description reported −8.9 % for NPAG. That number was wrong. The base had been built in a separate worktree, and since the hot ODE-solver code is instantiated inside the bench crate, that comparison was between two differently compiled solvers. The numbers below come from bench binaries built in the same checkout and run interleaved. They were measured with a since-dropped commit included (see below), which had no measurable effect.
bimodal_ke_npag(6 interleaved rounds, order rotated)bimodal_ke_npod(2 rounds)bimodal_ke_postprob(2 rounds)rayon::join) has been removed. It showed no measurable gain, because the Ψ computation already keeps every core busy, so the code stays sequential.Open question (not changed here)
When the error-model optimiser adopts a better Ψ, it keeps the weights from before (
Solution::with_stale_weights). From then on, Ψ and the weights no longer describe the same fit, and those weights flow into the cycle log, the result, NPAG's Σln(Ψ·w) convergence test and NPOD's expansion.Dropping that call adopts the IPM weights instead. That leaves NPAG unaffected, but
npodwith λ = 2 then converges in 15 cycles at −2LL −431.75, where it currently takes 18 cycles to reach −435.40. Because the change alters numerical results, it is left for a separate PR.Not addressed
Psi::nspp()returns the number of subjects (rows) andnsub()the number of support points: the two are swapped. That's a public-API fix for a separate PR.