Skip to content

feat: BestDose (BD-Michael)#315

Open
mnneely wants to merge 59 commits intomainfrom
BD-Michael
Open

feat: BestDose (BD-Michael)#315
mnneely wants to merge 59 commits intomainfrom
BD-Michael

Conversation

@mnneely
Copy link
Copy Markdown
Contributor

@mnneely mnneely commented Apr 9, 2026

Update BestDose objects and plots

Siel and others added 30 commits February 12, 2026 09:03
Add BestDose dose optimization feature ported from Pmetrics_rust bestdose branch:

- R/PM_bestdose.R: PM_bestdose and PM_bestdose_problem R6 classes for
  Bayesian dose optimization with support for concentration and AUC targets
- src/rust/src/bestdose_executor.rs: Rust backend for BestDose optimization
  using pmcore's BestDoseProblem with ODE model support
- Updated lib.rs with bestdose, bestdose_prepare, bestdose_optimize exports
- Updated extendr-wrappers.R with R-side wrapper functions
- Updated NAMESPACE with PM_bestdose, PM_bestdose_problem, bestdose exports
- Bumped pmcore dependency from 0.21.1 to 0.22.1 (required for bestdose)
- Added libloading dependency for dynamic model loading
- Added bestdose example data (past, prior, target CSVs) and test script
- Fixed executor.rs mutability issue for pmcore 0.22.1 compatibility
Reworked bd and bd_post R6 classes: bd_post$optimize() now returns a bd
object, and bd$new() supports one-shot usage. Removed dead Rust code
(bestdose(), bestdose_ode(), bestdose_analytical()). Fixed bias_weight
being NULL and print() breaking with variable dose counts. Updated PMcore
dep to use relative time_offset gap semantics.
- Combine clean one-shot/two-stage API with inline future=list(...) support
- Add top-5 weighted plot curves, triangle dose indicators, plot() method
- Fix .sim_future() dose recycling when past doses are present
…sed most of the logs for bestdose, needs more work
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR integrates the new BestDose workflow into the Pmetrics codebase, adding Rust-backed posterior preparation/optimization endpoints plus R-facing R6 objects (bd, bd_post), reporting, and supporting UX/options updates.

Changes:

  • Add Rust extendr exports and a new bestdose_executor module to compute/posterior + optimize doses via PMcore’s BestDose API.
  • Add R BestDose R6 classes, plotting, and an HTML report (RMarkdown template + report builder utilities).
  • Add user option support for platform-specific date parsing (date_format) and helper functions to check/download the latest R release.

Reviewed changes

Copilot reviewed 36 out of 43 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
src/rust/src/lib.rs Registers new extendr exports for BestDose and adjusts default log level.
src/rust/src/executor.rs Minor fit flow tweak (mutable result).
src/rust/src/bestdose_executor.rs New Rust BestDose executor: posterior handle + optimization + R conversions.
src/rust/Cargo.toml Updates Rust deps (PMcore source + libloading).
R/zzz.R Adds interactive attach checks for latest Pmetrics and platform-specific latest R.
R/PMutilities.R Adds latestR() and downloadR() helpers.
R/PMoptions.R Adds date_format option initialization + Shiny UI control.
R/PM_sim.R Adds validation of unrecognized ... args; sim logic tweaks.
R/PM_post.R Adjusts post-processing of pred.csv columns.
R/PM_model.R Adds quiet support to compile/run/sim; changes compile invocation behavior.
R/PM_data.R Date parsing improvements (PMoptions-aware) and plot pred handling tweaks.
R/PM_bestdose.R New BestDose R6 API (bd, bd_post) + plotting method.
R/PM_bestdose_report.R New BestDose report builder + metrics/plots helpers.
R/extendr-wrappers.R Adds R .Call() wrappers for new Rust BestDose exports.
NAMESPACE Exports bd, bd_post, latestR, downloadR; registers plot.bd.
man/summary.PM_sim.Rd Updates docs to use getPMoptions("digits") + group arg naming.
man/PMcheck.Rd Updates PMcheck() signature default for path.
man/PM_model.Rd Documents new quiet params and updated method signatures.
man/plot.bd.Rd New documentation for plot.bd.
man/latestR.Rd New documentation for latestR().
man/downloadR.Rd New documentation for downloadR().
man/bd.Rd New documentation for bd.
man/bd_post.Rd New documentation for bd_post.
inst/report/templates/bestdose.Rmd New RMarkdown template for BestDose HTML reports.
inst/options/PMoptions.json Adds date_format default.
inst/Examples/src/bestdose_target.csv New BestDose example target dataset.
inst/Examples/src/bestdose_target_pmcore.csv New BestDose example target dataset (PMcore variant).
inst/Examples/src/bestdose_prior.csv New BestDose example prior file.
inst/Examples/src/bestdose_prior_pmcore.csv New BestDose example prior file (PMcore variant).
inst/Examples/src/bestdose_past.csv New BestDose example past data file.
inst/Examples/Rscript/test_status_column.R New example/test script for report dose status handling.
inst/Examples/Rscript/test_future_vectors.R New example/test script for vectorized future specs.
inst/Examples/Rscript/run_bestdose_comparison.R New example script comparing bias/prior weights.
inst/Examples/Rscript/bestdose_simple_test.R New example script for basic BestDose usage.
DESCRIPTION Updates package metadata (version/URLs).
.gitignore Ensures inst/Examples is tracked while keeping other example outputs ignored.
_pkgdown.yml Adds BestDose + latestR/downloadR to pkgdown reference.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/rust/Cargo.toml Outdated
Comment on lines +130 to +135
let (library, (eq, meta)) =
unsafe { pmcore::prelude::pharmsol::exa::load::load::<ODE>(model_path) };

let settings = settings(params, meta.get_params(), "/tmp/bestdose")
.map_err(|e| format!("Failed to parse settings: {}", e))?;

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

settings(..., "/tmp/bestdose") hard-codes a Unix-only output path. This will fail on Windows and can also violate sandboxed environments. Use a platform-independent temp directory (e.g., std::env::temp_dir() / tempfile) or pass an output path from R/settings.

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +47
Self {
id: id.to_string(),
time: pred.time(),
observed: pred.obs().unwrap_or(0.0),
pop_mean: pred.pop_mean(),
pop_median: pred.pop_median(),
post_mean: pred.post_mean(),
post_median: pred.post_median(),
outeq: pred.outeq(),
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing observations are converted to 0.0 (unwrap_or(0.0)), which will be indistinguishable from a real zero observation and can bias plots/metrics. Preserve missingness by emitting an R NA (e.g., use Rfloat/Option<f64> mapping) or a dedicated missing sentinel handled on the R side.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good catch - should be Option<f64>

Comment on lines +72 to +78
// Predictions as data frame
let pred_rows: Vec<BestDosePredictionRow> = result
.predictions()
.predictions()
.iter()
.map(|p| BestDosePredictionRow::from_np_prediction(p, "subject_1"))
.collect();
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prediction rows are tagged with a hard-coded id ("subject_1"). If input data uses a different id (or multiple subjects), downstream joins/plots will be wrong. Use the actual subject id from the parsed target/past data when constructing BestDosePredictionRow.

Copilot uses AI. Check for mistakes.
Comment thread R/PM_data.R Outdated
Comment thread R/zzz.R Outdated
Comment thread R/PM_bestdose.R Outdated
Comment thread inst/Examples/Rscript/run_bestdose_comparison.R Outdated
Comment thread DESCRIPTION Outdated
Comment thread R/extendr-wrappers.R
Comment on lines +79 to 83
bestdose_prepare <- function(model_path, prior_path, past_data_path, params, kind) .Call(wrap__bestdose_prepare, model_path, prior_path, past_data_path, params, kind)

bestdose_optimize <- function(handle, target_data_path, time_offset, dose_min, dose_max, bias_weight, target_type) .Call(wrap__bestdose_optimize, handle, target_data_path, time_offset, dose_min, dose_max, bias_weight, target_type)


Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is marked "Generated by extendr: Do not edit by hand", but new wrappers were added manually. To avoid future regeneration overwriting changes, consider regenerating wrappers via rextendr::document() after adding the Rust #[extendr] exports, and keep this file fully generated.

Suggested change
bestdose_prepare <- function(model_path, prior_path, past_data_path, params, kind) .Call(wrap__bestdose_prepare, model_path, prior_path, past_data_path, params, kind)
bestdose_optimize <- function(handle, target_data_path, time_offset, dose_min, dose_max, bias_weight, target_type) .Call(wrap__bestdose_optimize, handle, target_data_path, time_offset, dose_min, dose_max, bias_weight, target_type)

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings April 9, 2026 15:56
Comment thread inst/Examples/Rscript/bestdose_simple_test.R
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 25 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/rust/src/lib.rs Outdated
Comment on lines +35 to +48
pub fn from_np_prediction(
pred: &pmcore::routines::output::predictions::NPPredictionRow,
id: &str,
) -> Self {
Self {
id: id.to_string(),
time: pred.time(),
observed: pred.obs().unwrap_or(0.0),
pop_mean: pred.pop_mean(),
pop_median: pred.pop_median(),
post_mean: pred.post_mean(),
post_median: pred.post_median(),
outeq: pred.outeq(),
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BestDosePredictionRow::from_np_prediction() converts missing observations to 0.0 via unwrap_or(0.0), which makes missing indistinguishable from a true 0 and will bias plots/metrics. Prefer preserving missingness (e.g., store observed as Option<f64> and map to R NA_real_).

Copilot uses AI. Check for mistakes.
Comment on lines +129 to +135
) -> std::result::Result<Self, String> {
let (library, (eq, meta)) =
unsafe { pmcore::prelude::pharmsol::exa::load::load::<ODE>(model_path) };

let settings = settings(params, meta.get_params(), "/tmp/bestdose")
.map_err(|e| format!("Failed to parse settings: {}", e))?;

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BestDosePosteriorHandle::new() hard-codes the settings output path to /tmp/bestdose. This is non-portable (e.g., Windows) and can fail in restricted environments. Prefer using a temp dir derived from the R session (e.g., R tempdir() passed down) or std::env::temp_dir() and ensure the directory exists before writing settings/logs.

Copilot uses AI. Check for mistakes.
Comment thread R/PM_bestdose.R Outdated
Comment thread R/PM_bestdose_report.R
if (has_past) {
max(x$past$standard_data$time, na.rm = TRUE)
} else {
x$start_offset %||% 0
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file uses the %||% infix operator (e.g., x$start_offset %||% 0), but %||% is not defined/imported in the package namespace. This will error at runtime unless %||% is provided. Consider using rlang::%||% (and import it) or defining a package-level %||% helper.

Suggested change
x$start_offset %||% 0
if (is.null(x$start_offset)) 0 else x$start_offset

Copilot uses AI. Check for mistakes.
Comment thread R/PM_bestdose.R Outdated
Comment thread inst/Examples/Rscript/bestdose_simple_test.R Outdated
Comment thread R/PM_bestdose.R Outdated
Copilot AI review requested due to automatic review settings April 15, 2026 16:33
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 83 out of 92 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/rust/src/lib.rs
Comment on lines 142 to 146

validate_paths(data_path, model_path);
let theta = parse_theta(theta);
let data = read_pmetrics(data_path).expect("Failed to parse data");
let data = read_pmetrics_for_kind(data_path, kind).expect("Failed to parse data");
let subjects = data.subjects();
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simulate_all() returns Result<...>, but it calls read_pmetrics_for_kind(...).expect(...), which will panic instead of returning an error to R. Please propagate the error (use ?) like simulate_one() does, so callers get a proper R error rather than a crash.

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +44
id: id.to_string(),
time: pred.time(),
observed: pred.obs().unwrap_or(0.0),
pop_mean: pred.pop_mean(),
pop_median: pred.pop_median(),
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing observations are currently coerced to 0.0 via unwrap_or(0.0), which is indistinguishable from a true zero and will bias downstream summaries/plots. Preserve missingness by emitting an NA-compatible value (e.g., store observed as Option<f64>/Rfloat and map None to NA_REAL on the R side).

Copilot uses AI. Check for mistakes.
Comment on lines +131 to +136
unsafe { pmcore::prelude::pharmsol::exa::load::load::<ODE>(model_path) };

let settings = settings(params, meta.get_params(), "/tmp/bestdose")
.map_err(|e| format!("Failed to parse settings: {}", e))?;

let (population_theta, prior_weights) =
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The settings initialization uses a hard-coded output path ("/tmp/bestdose"). This will fail or behave unexpectedly on non-Unix platforms (notably Windows) and can cause collisions between concurrent runs. Use a per-run temporary directory (e.g., std::env::temp_dir() + unique subdir) or accept an output path from the R side.

Copilot uses AI. Check for mistakes.
Comment thread R/engine_indices.R Outdated
Copilot AI review requested due to automatic review settings April 20, 2026 17:28
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 22 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread R/model_transpiler.R Outdated
Comment on lines +243 to +246
cond <- rust_args[[1]]
then_code <- expr_to_rust(args[[2]], params, covs, declared)
then_code <- expr_to_rust(args[[2]], params, covs, declared, index_mode = index_mode)
if (length(args) == 3) {
else_code <- expr_to_rust(args[[3]], params, covs, declared)
else_code <- expr_to_rust(args[[3]], params, covs, declared, index_mode = index_mode)
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expr_to_rust() now calls itself with index_mode = index_mode in the if branch, but expr_to_rust() has no index_mode parameter and there is no index_mode variable in scope. This will error at runtime when transpiling models. Either add index_mode to the function signature and propagate it through all recursive calls, or remove these named arguments if they aren't needed.

Copilot uses AI. Check for mistakes.
Comment thread R/PM_bestdose.R
Comment on lines +818 to +826
# Print target/future data for debugging
if (!quiet) {
cli::cli_h3("Target/Future data (for debugging)")
if (!is.null(x$future) && !is.null(x$future$standard_data)) {
print(x$future$standard_data)
} else {
cli::cli_alert_info("No future data available.")
}
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

plot.bd() prints the entire future dataset under a "for debugging" banner whenever quiet = FALSE. This will be very noisy in normal interactive use and in vignettes/reports. Consider removing this debug output or gating it behind an explicit debug flag / option so quiet = FALSE only controls informational messages.

Copilot uses AI. Check for mistakes.
Comment thread R/PM_bestdose.R
Comment on lines +216 to +252
if (is.null(future$dose) || is.null(future$target)) {
cli::cli_abort(c(
"x" = "Future setup must include both {.code dose} and {.code target}.",
"i" = "Provide {.code future = list(dose = ..., target = ...)}."
))
}

# Apply defaults
future$target_type <- future$target_type %||% "concentration"
future$frequency <- future$frequency %||% 24
future$route <- future$route %||% 0
future$number <- future$number %||% 1

# Determine effective number from vector lengths of dose, frequency, route
n_from_vectors <- max(
length(future$dose),
length(future$frequency),
length(future$route)
)
if (future$number < n_from_vectors) {
cli::cli_inform(c(
"!" = "{.arg number} ({future$number}) is less than the maximum length of
{.code dose}, {.code frequency}, or {.code route} ({n_from_vectors}).",
">" = "Setting {.arg number} to {n_from_vectors}."
))
future$number <- n_from_vectors
}
n <- as.integer(future$number)

# Recycle the last element of x to reach length n
recycle_last <- function(x, n) {
len <- length(x)
if (len >= n) {
return(x[seq_len(n)])
}
c(x, rep(x[len], n - len))
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.build_future_data() only checks is.null(future$dose)/is.null(future$frequency)/is.null(future$route), but not zero-length inputs. If a user passes dose = numeric(0) (or empty frequency/route), recycle_last() will index x[len] with len = 0 and error. Add validation that these vectors are numeric and have length >= 1 after defaults are applied.

Copilot uses AI. Check for mistakes.
Comment thread R/PM_bestdose_report.R
Comment on lines +200 to +211
fit <- actual |>
dplyr::left_join(pred, by = dplyr::join_by(id, time, outeq)) |>
dplyr::mutate(
obsSD = dplyr::coalesce(c0, 0) + dplyr::coalesce(c1, 0) * obs + dplyr::coalesce(c2, 0) * obs^2 + dplyr::coalesce(c3, 0) * obs^3,
obsSD = dplyr::if_else(!is.finite(obsSD) | obsSD <= 0, 1, obsSD),
d = pred - obs,
ds = d * d,
wd = d / obsSD,
wds = wd * wd,
kind = label
) |>
dplyr::filter(!is.na(obs), !is.na(pred))
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bd_fit_data() defines d = pred - obs, but bd_fit_metrics() (and the package-wide PM_op summary) treat d as an absolute error when computing MAE/MBASE/etc. This will flip the meaning of MAE/bias in the BestDose report. Use d = abs(pred - obs) (and similarly define wd from that) to match the existing metric definitions used elsewhere in the package.

Copilot uses AI. Check for mistakes.
Comment thread R/PM_bestdose_report.R
future_predictions <- x$result$predictions
if (!is.null(future_predictions) && nrow(future_predictions) > 0) {
future_predictions <- future_predictions |>
dplyr::filter(outeq == !!(outeq - 1)) |>
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bd_report_build() filters Rust future_predictions with outeq == outeq - 1, which will drop all predictions when outeq is the usual 1-based value used across Pmetrics. Unless Rust is intentionally emitting 0-based outeq (which would conflict with other Rust simulation exports), this should filter by outeq == outeq to keep the requested output equation.

Suggested change
dplyr::filter(outeq == !!(outeq - 1)) |>
dplyr::filter(outeq == !!outeq) |>

Copilot uses AI. Check for mistakes.
@mhovd mhovd changed the title Merge BestDose changes from BD-Michael feat: BestDose (BD-Michael) Apr 22, 2026
Comment thread output.txt
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably remove

Introduce a new simulate argument (default FALSE) to the bd R6 class constructor in R/PM_bestdose.R. The automatic calls to private$.sim_past() and private$.sim_future() are now executed only when simulate is TRUE, and a cli::cli_alert is shown when running simulations. This preserves previous behavior by default while allowing callers to opt out of immediate simulation (useful for performance or manual control).
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.

5 participants