Skip to content

refactor!: rework the non-parametric algorithm trait and tighten the fit API - #334

Draft
mhovd wants to merge 3 commits into
chore/pharmsol-mainfrom
refactor/algorithm-trait
Draft

mhovd wants to merge 3 commits into
chore/pharmsol-mainfrom
refactor/algorithm-trait

Conversation

@mhovd

@mhovd mhovd commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #333 (chore/pharmsol-main). Retarget to main once that merges. Until then CI won't run, because rust.yml only triggers for PRs into main.

Why

NonParametricRunner had ~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 out condensation, optimizations and expansion, then overrode fit to 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

Solution      theta + psi + weights + log-likelihood, private fields. Built only by
              solving for optimal weights (IPM) or from given weights, and replaced
              as a whole, so the four always describe the same fit.

FitState<E>   the problem (equation, data, error models, prior) + the current Solution
              + cycle bookkeeping, all private. Runners change it only through
              set_solution / adopt_error_models. Shared steps: estimate, condense,
              compute_psi.

Runner<E>     trait Runner<E> {
                  fn step(&mut self, &mut FitState<E>) -> Result<CycleOutcome>;
              }                                   // CycleOutcome = Continue | Converged
              Each algorithm only holds its own state:
                Npag   { config, ranges, eps, last_fine_objective, error_optimizer }
                Npod   { config, error_optimizer }
                Npmap
                Ncnpag { config }

Fit<E>        FitState + Box<dyn Runner> + max_cycles. Owns everything around a cycle:
              the counter, tracing span, cycle cap, stop file, summary logging and
              cycle log. Hands its state over as the result without copying.

An NPAG cycle now reads top to bottom:

fn step(&mut self, state: &mut FitState<E>) -> Result<CycleOutcome> {
    let mut theta = state.solution().theta().clone();
    if !state.is_first_cycle() {
        adaptative_grid(&mut theta, self.eps, &self.ranges, self.config.grid_tolerance)?;
    }
    let estimate = state.estimate(theta, self.config.progress && state.is_first_cycle())?;
    let solution = state.condense(estimate, self.config.prune_threshold, self.config.qr_tolerance)?;
    state.set_solution(solution);
    self.error_optimizer.optimize(state)?;
    Ok(if self.converged(state) { CycleOutcome::Converged } else { CycleOutcome::Continue })
}

Other changes:

  • Condensation (lambda pruning + QR) was duplicated in NPAG and NPOD. It now lives in FitState::condense.
  • Error-model optimizer: ErrorModelOptimizer owns its adaptive steps and works on candidate Solutions. It previously took 9 &mut arguments.
  • Stale weights: the optimizer has always kept the old weights when it adopts a better Ψ. That is now a single named call, Solution::with_stale_weights, documented as the one deliberate break of the invariant (see the open question below).
  • Algorithm impls: one blanket impl<A: Into<NonParametricAlgorithm>> Algorithm<E, NonParametric> for A replaces the 5 hand-written impls. The parametric side gets the same treatment.
  • Unwraps: the NPOD candidate optimizer and the zero-probability diagnostics no longer 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:

  1. objf is now −2LL. It used to be the log-likelihood (and cycles.csv labelled it neg2ll).
  2. They report cycles() == 1, not 0. tests/onecomp.rs is updated for this.
  3. fit_controller(NpmapConfig) now writes the cycle log entry. It used to be empty on that path.
  4. They now log the cycle summary, like NPAG and NPOD.

Stop file:

  • The stop file is still honoured by fit_with and fit_controller, and a stale one is still removed at start. This is now documented on the nonparametric module.
  • Fit::new itself no longer touches the filesystem. The public entry points opt in with watch_stop_file().
  • NCNPAG's nested per-point NPAG runs no longer delete or react to the stop file. Previously each nested run deleted it, so a stop created during NCNPAG stopped at most one point's refinement.

Minor:

  • On the first cycle, NPCycle::delta_objf is inf instead of ~1e30.
  • NPOD now runs the zero-probability check after condensation, as NPAG already did.
  • The "converged" log line is the same for every algorithm.

API changes

  • Removed: NonParametricRunner and the public structs NPAG, NPOD, NPMAP and NCNPAG. Their constructors were pub(crate), so nothing outside the crate could build one.
  • NpodConfig gains objective_tolerance, min_distance, prune_threshold and qr_tolerance, with setters. These were hard-coded constants; the defaults are unchanged. #[serde(default)] keeps older serialised configs loading, and a test covers this.
  • FitController gains weights(), log_likelihood() and objf(); before this there was no way to read the weights while a fit ran. likelihood() and n2ll() are removed. objf is now the name everywhere in the Rust API (result, cycle log, controller, FitResult) and always means −2LL. The neg2ll CSV column is unchanged.
  • Looser bounds: FitController<E> and fit_controller go from E: Equation + Send + 'static to E: Equation. That is no looser in practice, since Equation: Clone + Send + Sync + 'static.

Public API (third commit, feat!)

These changes are breaking, and nothing is kept as a deprecated alias.

  1. A stopped fit stays stopped. FitController::step used to run another full cycle even after the fit had stopped, despite its docs saying further steps changed nothing. It also overwrote a request_stop(), which is why the docs said to call request_stop "just before into_result". Now:
    • once a fit has stopped, step does nothing and returns the final status;
    • request_stop is final;
    • a fit that already stopped keeps its reason.
  2. NonParametricResult::status() and stop_reason(). Telling MaxCycles, StopFile and Aborted apart no longer needs the cycle log.
  3. FitController::run_until(|fit| -> bool) replaces fit_with_observer. FitObserver and CycleFlow are removed. The closure runs after every cycle, the last one included, so it also works for progress reporting:
    let result = problem
        .fit_controller(NpagConfig::new())?
        .run_until(|fit| {
            println!("cycle {} | -2LL {:.4}", fit.cycle(), fit.objf());
            fit.cycle() >= 50
        })?;
  4. get_theta() → theta().
  5. PredictionGrid replaces the positional idelta, tad floats in predictions, write_predictions, write_json, write_outputs and NPPredictions::calculate:
    result.write_outputs("outputs/run", PredictionGrid::new().idelta(0.5).tad(12.0))?;
    NPPredictions::calculate also stops cloning the whole dataset before expanding it.
  6. ZeroProbabilityError. Subjects with zero probability now fail with a typed error that names them, which you can recover with err.downcast_ref::<ZeroProbabilityError>():
    • With log-scaled Ψ, this failure happens inside 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 in FitState were effectively unreachable.
    • Both now happen on that path.
  7. One way to pick an algorithm. Docs, examples, tests and benches use the config structs (NpagConfig::new()). NonParametricAlgorithm is 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

  • Fingerprint. A throwaway harness (not committed) ran every algorithm on bimodal_ke before 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; their objf is exactly −2× the old value. The last commit is bit-identical to the one before it for all four algorithms.
  • Tests: cargo test --all-features: 95 passed. The new tests cover:
    • NPMAP objf = −2LL;
    • Solution::optimal agreeing with the marginal likelihood;
    • controller θ, weights and Ψ staying aligned between steps;
    • old NpodConfig JSON still loading;
    • stopped fits staying stopped, and request_stop keeping an earlier reason;
    • run_until (every cycle seen; both stop paths);
    • ZeroProbabilityError detection and downcasting.
  • Clippy: cargo clippy --all-targets --all-features reports 1 warning, which predates this branch. The other old one, a redundant + Sync bound, is fixed.
  • Examples: bimodal_ke gives 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_algorithms and bestdose (with --features dsl) all run cleanly.
  • Fingerprint across commits: the third commit is bit-identical to the second. The harness's observer run was ported to run_until with 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.

Bench base this PR change
bimodal_ke_npag (6 interleaved rounds, order rotated) 2.88 s 2.93–3.00 s none measurable (see below)
bimodal_ke_npod (2 rounds) 0.997 s 0.946 s ~−5 %
bimodal_ke_postprob (2 rounds) 461 ms 443 ms ~−4 %
  • NPAG: single-threaded, a full fit after the second commit retires 505.5 vs 504.9 billion instructions compared with the first commit (+0.13 %), with the same CPU cycles and wall time. Multi-threaded runs of the same binaries, alternating order, agree within ~1 %. Criterion runs on this laptop drift by ±5 %, which is the noise floor.
  • NPOD and postprob: the gains come from turning the result into a consuming move instead of cloning data, Ψ and θ, and from dropping redundant clones.
  • Dropped commit: a commit that ran the error-model optimiser's "up" and "down" trials concurrently (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 npod with λ = 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) and nsub() the number of support points: the two are swapped. That's a public-API fix for a separate PR.

…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.
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

ProjectPMcore
Branchrefactor/algorithm-trait
Testbedmhovd-pgx
Click to view all benchmark results
BenchmarkLatencyBenchmark 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%)
🐰 View full continuous benchmarking report in Bencher

- 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
mhovd force-pushed the refactor/algorithm-trait branch from e446d1a to 56dc862 Compare October 29, 2026 13:26
- 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.
@mhovd mhovd changed the title refactor: rework the non-parametric algorithm trait refactor!: rework the non-parametric algorithm trait and tighten the fit API Oct 29, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant