pmcore/bestdose/
mod.rs

1//! # BestDose Algorithm
2//!
3//! Bayesian dose optimization algorithm that finds optimal dosing regimens to achieve
4//! target drug concentrations or cumulative AUC (Area Under the Curve) values.
5//!
6//! The BestDose algorithm combines Bayesian posterior estimation with dual optimization
7//! to balance patient-specific adaptation and population-level robustness.
8//!
9//! # Quick Start
10//!
11//! ```rust,no_run,ignore
12//! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange};
13//!
14//! # fn example(population_theta: pmcore::structs::theta::Theta,
15//! #            population_weights: pmcore::structs::weights::Weights,
16//! #            past_data: pharmsol::prelude::Subject,
17//! #            target: pharmsol::prelude::Subject,
18//! #            eq: pharmsol::prelude::ODE,
19//! #            error_models: pharmsol::prelude::ErrorModels,
20//! #            settings: pmcore::routines::settings::Settings)
21//! #            -> anyhow::Result<()> {
22//! // Create optimization problem
23//! let problem = BestDoseProblem::new(
24//!     &population_theta,                    // Population support points from NPAG
25//!     &population_weights,                  // Population probabilities
26//!     Some(past_data),                 // Patient history (None = use prior)
27//!     target,                          // Future template with targets
28//!     None,                            // time_offset (None = standard mode)
29//!     eq,                              // PK/PD model
30//!     error_models,                    // Error specifications
31//!     DoseRange::new(0.0, 1000.0),     // Dose constraints (0-1000 mg)
32//!     0.5,                             // bias_weight: 0=personalized, 1=population
33//!     settings,                        // NPAG settings
34//!     Target::Concentration,           // Target type
35//! )?;
36//!
37//! // Run optimization
38//! let result = problem.optimize()?;
39//!
40//! // Extract results
41//! println!("Optimal dose: {:?} mg", result.dose);
42//! println!("Final cost: {}", result.objf);
43//! println!("Method: {}", result.optimization_method);  // "posterior" or "uniform"
44//! # Ok(())
45//! # }
46//! ```
47//!
48//! # Algorithm Overview (Three Stages)
49//!
50//! ```text
51//! ┌─────────────────────────────────────────────────────────────────┐
52//! │ STAGE 1: Posterior Density Calculation                         │
53//! │                                                                 │
54//! │  Prior (N points)                                              │
55//! │      ↓                                                         │
56//! │  Step 1.1: NPAGFULL11 - Bayesian Filtering                    │
57//! │      Calculate P(data|θᵢ) for each support point              │
58//! │      Apply Bayes rule: P(θᵢ|data) ∝ P(data|θᵢ) × P(θᵢ)       │
59//! │      Filter: Keep points where P(θᵢ|data) > 1e-100 × max      │
60//! │      ↓                                                         │
61//! │  Filtered Posterior (M points, typically 5-50)                │
62//! │      ↓                                                         │
63//! │  Step 1.2: NPAGFULL - Local Refinement                        │
64//! │      For each filtered point:                                 │
65//! │          Run full NPAG optimization                           │
66//! │          Find refined "daughter" point                        │
67//! │      ↓                                                         │
68//! │  Refined Posterior (M points with NPAGFULL11 weights)         │
69//! └─────────────────────────────────────────────────────────────────┘
70//!
71//! ┌─────────────────────────────────────────────────────────────────┐
72//! │ STAGE 2: Dual Optimization                                     │
73//! │                                                                 │
74//! │  Optimization 1: Posterior Weights (Patient-Specific)          │
75//! │      Minimize Cost = (1-λ)×Variance + λ×Bias²                 │
76//! │      Using NPAGFULL11 posterior weights                        │
77//! │      ↓                                                         │
78//! │  Result 1: (doses₁, cost₁)                                     │
79//! │                                                                 │
80//! │  Optimization 2: Uniform Weights (Population)                  │
81//! │      Minimize Cost = (1-λ)×Variance + λ×Bias²                 │
82//! │      Using uniform weights (1/M for all points)                │
83//! │      ↓                                                         │
84//! │  Result 2: (doses₂, cost₂)                                     │
85//! │                                                                 │
86//! │  Select Best: min(cost₁, cost₂)                                │
87//! └─────────────────────────────────────────────────────────────────┘
88//!
89//! ┌─────────────────────────────────────────────────────────────────┐
90//! │ STAGE 3: Final Predictions                                     │
91//! │                                                                 │
92//! │  Calculate predictions with optimal doses                       │
93//! │  For AUC targets: Use dense time grid + trapezoidal rule      │
94//! │    - AUCFromZero: Cumulative from time 0                       │
95//! │    - AUCFromLastDose: Interval from last dose                  │
96//! │  Return: Optimal doses, cost, predictions, method used         │
97//! └─────────────────────────────────────────────────────────────────┘
98//! ```
99//!
100//! # Mathematical Foundation
101//!
102//! ## Bayesian Posterior
103//!
104//! The posterior density is calculated via Bayes' rule:
105//!
106//! ```text
107//! P(θ | data) = P(data | θ) × P(θ) / P(data)
108//! ```
109//!
110//! Where:
111//! - `P(θ | data)`: Posterior (patient-specific parameters)
112//! - `P(data | θ)`: Likelihood (from error model)
113//! - `P(θ)`: Prior (from population)
114//! - `P(data)`: Normalizing constant
115//!
116//! ## Cost Function
117//!
118//! The optimization minimizes a hybrid cost function:
119//!
120//! ```text
121//! Cost = (1-λ) × Variance + λ × Bias²
122//! ```
123//!
124//! **Variance Term** (Patient-Specific Performance):
125//! ```text
126//! Variance = Σᵢ P(θᵢ|data) × Σⱼ (target[j] - pred[i,j])²
127//! ```
128//! Expected squared error using posterior weights.
129//!
130//! **Bias Term** (Population-Level Performance):
131//! ```text
132//! Bias² = Σⱼ (target[j] - E[pred[j]])²
133//! where E[pred[j]] = Σᵢ P(θᵢ) × pred[i,j]
134//! ```
135//! Squared deviation from population mean prediction using prior weights.
136//!
137//! **Bias Weight Parameter (λ)**:
138//! - `λ = 0.0`: Fully personalized (minimize variance only)
139//! - `λ = 0.5`: Balanced hybrid approach
140//! - `λ = 1.0`: Population-based (minimize bias from population)
141//!
142//! # Examples
143//!
144//! ## Single Dose Optimization
145//!
146//! ```rust,no_run,ignore
147//! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange};
148//! use pharmsol::prelude::Subject;
149//!
150//! # fn example(population_theta: pmcore::structs::theta::Theta,
151//! #            population_weights: pmcore::structs::weights::Weights,
152//! #            past: pharmsol::prelude::Subject,
153//! #            eq: pharmsol::prelude::ODE,
154//! #            error_models: pharmsol::prelude::ErrorModels,
155//! #            settings: pmcore::routines::settings::Settings)
156//! #            -> anyhow::Result<()> {
157//! // Define target: 5 mg/L at 24 hours
158//! let target = Subject::builder("patient_001")
159//!     .bolus(0.0, 100.0, 0)           // Initial dose (will be optimized)
160//!     .observation(24.0, 5.0, 0)      // Target: 5 mg/L at 24h
161//!     .build();
162//!
163//! let problem = BestDoseProblem::new(
164//!     &population_theta, &population_weights, Some(past), target, None,
165//!     eq, error_models,
166//!     DoseRange::new(10.0, 500.0),    // 10-500 mg allowed
167//!     0.3,                             // Slight population emphasis
168//!     settings, Target::Concentration,
169//! )?;
170//!
171//! let result = problem.optimize()?;
172//! println!("Optimal dose: {} mg", result.dose[0]);
173//! # Ok(())
174//! # }
175//! ```
176//!
177//! ## Multiple Doses with AUC Target
178//!
179//! ```rust,no_run,ignore
180//! use pmcore::bestdose::{BestDoseProblem, Target, DoseRange};
181//! use pharmsol::prelude::Subject;
182//!
183//! # fn example(population_theta: pmcore::structs::theta::Theta,
184//! #            population_weights: pmcore::structs::weights::Weights,
185//! #            past: pharmsol::prelude::Subject,
186//! #            eq: pharmsol::prelude::ODE,
187//! #            error_models: pharmsol::prelude::ErrorModels,
188//! #            settings: pmcore::routines::settings::Settings)
189//! #            -> anyhow::Result<()> {
190//! // Target: Achieve AUC₂₄ = 400 mg·h/L
191//! let target = Subject::builder("patient_002")
192//!     .bolus(0.0, 100.0, 0)           // Dose 1 (optimized)
193//!     .bolus(12.0, 100.0, 0)          // Dose 2 (optimized)
194//!     .observation(24.0, 400.0, 0)    // Target: AUC₂₄ = 400
195//!     .build();
196//!
197//! let problem = BestDoseProblem::new(
198//!     &population_theta, &population_weights, Some(past), target, None,
199//!     eq, error_models,
200//!     DoseRange::new(50.0, 300.0),
201//!     0.0,                             // Full personalization
202//!     settings, Target::AUCFromZero,   // Cumulative AUC target!
203//! )?;
204//!
205//! let result = problem.optimize()?;
206//! println!("Dose 1: {} mg at t=0", result.dose[0]);
207//! println!("Dose 2: {} mg at t=12", result.dose[1]);
208//! if let Some(auc) = result.auc_predictions {
209//!     println!("Predicted AUC₂₄: {} mg·h/L", auc[0].1);
210//! }
211//! # Ok(())
212//! # }
213//! ```
214//!
215//! ## Population-Only Optimization
216//!
217//! ```rust,no_run,ignore
218//! # use pmcore::bestdose::{BestDoseProblem, Target, DoseRange};
219//! # fn example(population_theta: pmcore::structs::theta::Theta,
220//! #            population_weights: pmcore::structs::weights::Weights,
221//! #            target: pharmsol::prelude::Subject,
222//! #            eq: pharmsol::prelude::ODE,
223//! #            error_models: pharmsol::prelude::ErrorModels,
224//! #            settings: pmcore::routines::settings::Settings)
225//! #            -> anyhow::Result<()> {
226//! // No patient history - use population prior directly
227//! let problem = BestDoseProblem::new(
228//!     &population_theta, &population_weights,
229//!     None,                            // No past data
230//!     target, None,                    // time_offset
231//!     eq, error_models,
232//!     DoseRange::new(0.0, 1000.0),
233//!     1.0,                             // Full population weighting
234//!     settings,
235//!     Target::Concentration,
236//! )?;
237//!
238//! let result = problem.optimize()?;
239//! // Returns population-typical dose
240//! # Ok(())
241//! # }
242//! ```
243//!
244//! # Configuration
245//!
246//! ## Key Parameters
247//!
248//! - **`bias_weight` (λ)**: Controls personalization level
249//!   - `0.0`: Minimize patient-specific variance (full personalization)
250//!   - `1.0`: Minimize deviation from population (robustness)
251//!   
252//! - **`max_cycles`**: NPAGFULL refinement iterations
253//!   - `0`: Skip refinement (use filtered points directly)
254//!   - `100-500`: Typical range for refinement
255//!   
256//! - **`doserange`**: Dose constraints
257//!   - Set clinically appropriate bounds for your drug
258//!   
259//! - **`target_type`**: Optimization target
260//!   - `Target::Concentration`: Direct concentration targets
261//!   - `Target::AUCFromZero`: Cumulative AUC from time 0
262//!   - `Target::AUCFromLastDose`: Interval AUC from last dose
263//!
264//! ## Performance Tuning
265//!
266//! For faster optimization:
267//! ```rust,no_run,ignore
268//! # use pmcore::bestdose::{BestDoseProblem, Target, DoseRange};
269//! # fn example(population_theta: pmcore::structs::theta::Theta,
270//! #            population_weights: pmcore::structs::weights::Weights,
271//! #            target: pharmsol::prelude::Subject,
272//! #            eq: pharmsol::ODE,
273//! #            error_models: pharmsol::prelude::ErrorModels,
274//! #            mut settings: pmcore::routines::settings::Settings)
275//! #            -> anyhow::Result<()> {
276//! // Reduce refinement cycles
277//! let problem = BestDoseProblem::new(
278//!     &population_theta, &population_weights, None, target, None,
279//!     eq, error_models,
280//!     DoseRange::new(0.0, 1000.0), 0.5,
281//!     settings.clone(),
282//!     Target::Concentration,
283//! )?;
284//!
285//! // For AUC: use coarser time grid
286//! settings.predictions().idelta = 30.0;  // 30-minute intervals
287//! # Ok(())
288//! # }
289//! ```
290//!
291//! # See Also
292//!
293//! - [`BestDoseProblem`]: Main entry point for optimization
294//! - [`BestDoseResult`]: Output structure with optimal doses
295//! - [`Target`]: Enum for concentration vs AUC targets
296//! - [`DoseRange`]: Dose constraint specification
297
298pub mod cost;
299mod optimization;
300mod posterior;
301pub mod predictions;
302mod types;
303
304// Re-export public API
305pub use types::{BestDoseProblem, BestDoseResult, DoseRange, Target};
306
307/// Helper function to concatenate past and future subjects (Option 3: Fortran MAKETMP approach)
308///
309/// This mimics Fortran's MAKETMP subroutine logic:
310/// 1. Takes doses (only doses, not observations) from past subject
311/// 2. Offsets all future subject event times by `time_offset`
312/// 3. Combines into single continuous subject
313///
314/// # Arguments
315///
316/// * `past` - Subject with past history (only doses will be used)
317/// * `future` - Subject template for future (all events: doses + observations)
318/// * `time_offset` - Time offset to apply to all future events
319///
320/// # Returns
321///
322/// Combined subject with:
323/// - Past doses at original times [0, time_offset)
324/// - Future doses + observations at offset times [time_offset, ∞)
325///
326/// # Example
327///
328/// ```rust,ignore
329/// // Past: dose at t=0, observation at t=6 (patient has been on therapy 6 hours)
330/// let past = Subject::builder("patient")
331///     .bolus(0.0, 500.0, 0)
332///     .observation(6.0, 15.0, 0)  // 15 mg/L at 6 hours
333///     .build();
334///
335/// // Future: dose at t=0 (relative), target at t=24 (relative)
336/// let future = Subject::builder("patient")
337///     .bolus(0.0, 100.0, 0)  // Dose to optimize, will be at t=6 absolute
338///     .observation(24.0, 10.0, 0)  // Target at t=30 absolute
339///     .build();
340///
341/// // Concatenate with time_offset = 6.0
342/// let combined = concatenate_past_and_future(&past, &future, 6.0);
343/// // Result: dose at t=0 (fixed, 500mg), dose at t=6 (optimizable, 100mg initial),
344/// //         observation target at t=30 (10 mg/L)
345/// ```
346fn concatenate_past_and_future(
347    past: &pharmsol::prelude::Subject,
348    future: &pharmsol::prelude::Subject,
349    time_offset: f64,
350) -> pharmsol::prelude::Subject {
351    use pharmsol::prelude::*;
352
353    let mut builder = Subject::builder(past.id());
354
355    // Add past doses only (skip observations from past)
356    for occasion in past.occasions() {
357        for event in occasion.events() {
358            match event {
359                Event::Bolus(bolus) => {
360                    builder = builder.bolus(bolus.time(), bolus.amount(), bolus.input());
361                }
362                Event::Infusion(inf) => {
363                    builder =
364                        builder.infusion(inf.time(), inf.amount(), inf.input(), inf.duration());
365                }
366                Event::Observation(_) => {
367                    // Skip observations from past (they were already used for posterior)
368                }
369            }
370        }
371    }
372
373    // Add future events with time offset
374    for occasion in future.occasions() {
375        for event in occasion.events() {
376            match event {
377                Event::Bolus(bolus) => {
378                    builder =
379                        builder.bolus(bolus.time() + time_offset, bolus.amount(), bolus.input());
380                }
381                Event::Infusion(inf) => {
382                    builder = builder.infusion(
383                        inf.time() + time_offset,
384                        inf.amount(),
385                        inf.input(),
386                        inf.duration(),
387                    );
388                }
389                Event::Observation(obs) => {
390                    builder = match obs.value() {
391                        Some(val) => {
392                            builder.observation(obs.time() + time_offset, val, obs.outeq())
393                        }
394                        None => builder,
395                    };
396                }
397            }
398        }
399    }
400
401    builder.build()
402}
403
404/// Calculate which doses are optimizable based on dose amounts
405///
406/// Returns a boolean mask where:
407/// - `true` = dose amount is 0 (placeholder, optimizable)
408/// - `false` = dose amount > 0 (fixed past dose)
409///
410/// This allows users to specify a combined subject with:
411/// - Non-zero doses for past doses (e.g., 500 mg at t=0) - these are fixed
412/// - Zero doses as placeholders for future doses (e.g., 0 mg at t=6) - these are optimized
413///
414/// # Arguments
415///
416/// * `subject` - The subject with both fixed and placeholder doses
417///
418/// # Returns
419///
420/// Vector of booleans, one per dose in the subject
421///
422/// # Example
423///
424/// ```rust,ignore
425/// let subject = Subject::builder("patient")
426///     .bolus(0.0, 500.0, 0)    // Past dose (fixed) - mask[0] = false
427///     .bolus(6.0, 0.0, 0)      // Future dose (optimize) - mask[1] = true
428///     .observation(30.0, 10.0, 0)
429///     .build();
430/// let mask = calculate_dose_optimization_mask(&subject);
431/// assert_eq!(mask, vec![false, true]);
432/// ```
433fn calculate_dose_optimization_mask(subject: &pharmsol::prelude::Subject) -> Vec<bool> {
434    use pharmsol::prelude::*;
435
436    let mut mask = Vec::new();
437
438    for occasion in subject.occasions() {
439        for event in occasion.events() {
440            match event {
441                Event::Bolus(bolus) => {
442                    // Dose is optimizable if amount is 0 (placeholder)
443                    mask.push(bolus.amount() == 0.0);
444                }
445                Event::Infusion(infusion) => {
446                    // Infusion is optimizable if amount is 0 (placeholder)
447                    mask.push(infusion.amount() == 0.0);
448                }
449                Event::Observation(_) => {
450                    // Observations don't go in the mask
451                }
452            }
453        }
454    }
455
456    mask
457}
458
459use anyhow::Result;
460use pharmsol::prelude::*;
461use pharmsol::ODE;
462
463use crate::routines::settings::Settings;
464use crate::structs::theta::Theta;
465use crate::structs::weights::Weights;
466
467// ═════════════════════════════════════════════════════════════════════════════
468// Helper Functions for STAGE 1: Posterior Density Calculation
469// ═════════════════════════════════════════════════════════════════════════════
470
471/// Validate time_offset parameter for past/future separation mode
472fn validate_time_offset(time_offset: f64, past_data: &Option<Subject>) -> Result<()> {
473    if let Some(past_subject) = past_data {
474        let max_past_time = past_subject
475            .occasions()
476            .iter()
477            .flat_map(|occ| occ.events())
478            .map(|event| match event {
479                Event::Bolus(b) => b.time(),
480                Event::Infusion(i) => i.time(),
481                Event::Observation(o) => o.time(),
482            })
483            .fold(0.0_f64, |max, time| max.max(time));
484
485        if time_offset < max_past_time {
486            return Err(anyhow::anyhow!(
487                "Invalid time_offset: {} is before the last past_data event at time {}. \
488                time_offset must be >= the maximum time in past_data to avoid time travel!",
489                time_offset,
490                max_past_time
491            ));
492        }
493    }
494    Ok(())
495}
496
497/// Calculate posterior density (STAGE 1: Two-step process)
498///
499/// # Algorithm Flow (Matches Diagram)
500///
501/// ```text
502/// Prior Density (N points)
503///     ↓
504/// Has past data with observations?
505///     ↓ Yes              ↓ No
506/// Step 1.1:          Use prior
507/// NPAGFULL11         directly
508/// (Bayesian Filter)
509///     ↓
510/// Filtered Posterior (M points)
511///     ↓
512/// Step 1.2:
513/// NPAGFULL
514/// (Refine each point)
515///     ↓
516/// Refined Posterior
517/// (M points with NPAGFULL11 weights)
518/// ```
519///
520/// # Returns
521///
522/// Tuple: (posterior_theta, posterior_weights, filtered_population_weights, past_subject)
523fn calculate_posterior_density(
524    population_theta: &Theta,
525    population_weights: &Weights,
526    past_data: Option<&Subject>,
527    eq: &ODE,
528    error_models: &ErrorModels,
529    settings: &Settings,
530) -> Result<(Theta, Weights, Weights, Subject)> {
531    match past_data {
532        None => {
533            tracing::info!("  No past data → using prior directly");
534            Ok((
535                population_theta.clone(),
536                population_weights.clone(),
537                population_weights.clone(),
538                Subject::builder("Empty").build(),
539            ))
540        }
541        Some(past_subject) => {
542            // Check if past data has observations
543            let has_observations = !past_subject.occasions().is_empty()
544                && past_subject.occasions().iter().any(|occ| {
545                    occ.events()
546                        .iter()
547                        .any(|e| matches!(e, Event::Observation(_)))
548                });
549
550            if !has_observations {
551                tracing::info!("  Past data has no observations → using prior directly");
552                Ok((
553                    population_theta.clone(),
554                    population_weights.clone(),
555                    population_weights.clone(),
556                    past_subject.clone(),
557                ))
558            } else {
559                // Two-step posterior calculation
560                tracing::info!("  Past data with observations → calculating two-step posterior");
561                tracing::info!("    Step 1.1: NPAGFULL11 (Bayesian filtering)");
562                tracing::info!("    Step 1.2: NPAGFULL (local refinement)");
563
564                let past_data_obj = Data::new(vec![past_subject.clone()]);
565
566                let (posterior_theta, posterior_weights, filtered_population_weights) =
567                    posterior::calculate_two_step_posterior(
568                        population_theta,
569                        population_weights,
570                        &past_data_obj,
571                        eq,
572                        error_models,
573                        settings,
574                    )?;
575
576                Ok((
577                    posterior_theta,
578                    posterior_weights,
579                    filtered_population_weights,
580                    past_subject.clone(),
581                ))
582            }
583        }
584    }
585}
586
587/// Prepare target subject by handling past/future concatenation if needed
588///
589/// # Returns
590///
591/// Tuple: (final_target, final_past_data)
592fn prepare_target_subject(
593    past_subject: Subject,
594    target: Subject,
595    time_offset: Option<f64>,
596) -> Result<(Subject, Subject)> {
597    match time_offset {
598        None => {
599            tracing::info!("  Mode: Standard (single subject)");
600            Ok((target, past_subject))
601        }
602        Some(t) => {
603            tracing::info!("  Mode: Past/Future separation (Fortran MAKETMP approach)");
604            tracing::info!("  Current time boundary: {} hours", t);
605            tracing::info!("  Concatenating past and future subjects...");
606
607            let combined = concatenate_past_and_future(&past_subject, &target, t);
608
609            // Log dose structure
610            let mask = calculate_dose_optimization_mask(&combined);
611            let num_fixed = mask.iter().filter(|&&x| !x).count();
612            let num_optimizable = mask.iter().filter(|&&x| x).count();
613            tracing::info!("    Fixed doses (from past): {}", num_fixed);
614            tracing::info!("    Optimizable doses (from future): {}", num_optimizable);
615
616            Ok((combined, past_subject))
617        }
618    }
619}
620
621// ═════════════════════════════════════════════════════════════════════════════
622
623impl BestDoseProblem {
624    /// Create a new BestDose problem with automatic posterior calculation
625    ///
626    /// This is the main entry point for the BestDose algorithm.
627    ///
628    /// # Algorithm Structure (Matches Flowchart)
629    ///
630    /// ```text
631    /// ┌─────────────────────────────────────────┐
632    /// │ STAGE 1: Posterior Density Calculation  │
633    /// │                                         │
634    /// │  Prior Density (N points)              │
635    /// │      ↓                                 │
636    /// │  Has past data with observations?      │
637    /// │      ↓ Yes          ↓ No              │
638    /// │  Step 1.1:      Use prior             │
639    /// │  NPAGFULL11     directly               │
640    /// │  (Filter)                              │
641    /// │      ↓                                 │
642    /// │  Step 1.2:                             │
643    /// │  NPAGFULL                              │
644    /// │  (Refine)                              │
645    /// │      ↓                                 │
646    /// │  Posterior Density                     │
647    /// └─────────────────────────────────────────┘
648    /// ```
649    ///
650    /// # Parameters
651    ///
652    /// * `population_theta` - Population support points from NPAG
653    /// * `population_weights` - Population probabilities
654    /// * `past_data` - Patient history (None = use prior directly)
655    /// * `target` - Future dosing template with targets
656    /// * `time_offset` - Optional time offset for concatenation (None = standard mode, Some(t) = Fortran mode)
657    /// * `eq` - Pharmacokinetic/pharmacodynamic model
658    /// * `error_models` - Error model specifications
659    /// * `doserange` - Allowable dose constraints
660    /// * `bias_weight` - λ ∈ [0,1]: 0=personalized, 1=population
661    /// * `settings` - NPAG settings for posterior refinement
662    /// * `max_cycles` - NPAGFULL cycles (0=skip refinement, 500=default)
663    /// * `target_type` - Concentration or AUC targets
664    ///
665    /// # Returns
666    ///
667    /// BestDoseProblem ready for `optimize()`
668    #[allow(clippy::too_many_arguments)]
669    pub fn new(
670        population_theta: &Theta,
671        population_weights: &Weights,
672        past_data: Option<Subject>,
673        target: Subject,
674        time_offset: Option<f64>,
675        eq: ODE,
676        doserange: DoseRange,
677        bias_weight: f64,
678        settings: Settings,
679        target_type: Target,
680    ) -> Result<Self> {
681        tracing::info!("╔══════════════════════════════════════════════════════════╗");
682        tracing::info!("║            BestDose Algorithm: STAGE 1                   ║");
683        tracing::info!("║           Posterior Density Calculation                  ║");
684        tracing::info!("╚══════════════════════════════════════════════════════════╝");
685
686        // Validate input if using past/future separation mode
687        if let Some(t) = time_offset {
688            validate_time_offset(t, &past_data)?;
689        }
690
691        // ═════════════════════════════════════════════════════════════
692        // STAGE 1: Calculate Posterior Density
693        // ═════════════════════════════════════════════════════════════
694        let (posterior_theta, posterior_weights, filtered_population_weights, past_subject) =
695            calculate_posterior_density(
696                population_theta,
697                population_weights,
698                past_data.as_ref(),
699                &eq,
700                &settings.errormodels,
701                &settings,
702            )?;
703
704        // Handle past/future concatenation if needed
705        let (final_target, _) = prepare_target_subject(past_subject, target, time_offset)?;
706
707        tracing::info!("╔══════════════════════════════════════════════════════════╗");
708        tracing::info!("║              Stage 1 Complete - Ready for Optimization   ║");
709        tracing::info!("╚══════════════════════════════════════════════════════════╝");
710        tracing::info!("  Support points: {}", posterior_theta.matrix().nrows());
711        tracing::info!("  Target type: {:?}", target_type);
712        tracing::info!("  Bias weight (λ): {}", bias_weight);
713
714        Ok(BestDoseProblem {
715            target: final_target,
716            target_type,
717            population_weights: filtered_population_weights,
718            theta: posterior_theta,
719            posterior: posterior_weights,
720            eq,
721            settings,
722            doserange,
723            bias_weight,
724        })
725    }
726
727    /// Run the complete BestDose optimization algorithm
728    ///
729    /// # Algorithm Flow (Matches Diagram!)
730    ///
731    /// ```text
732    /// ┌─────────────────────────────────────────┐
733    /// │ STAGE 1: Posterior Calculation          │
734    /// │         [COMPLETED in new()]             │
735    /// └────────────┬────────────────────────────┘
736    ///              ↓
737    /// ┌─────────────────────────────────────────┐
738    /// │ STAGE 2: Dual Optimization              │
739    /// │                                         │
740    /// │  Optimization 1: Posterior Weights      │
741    /// │    (Patient-specific)                   │
742    /// │      ↓                                  │
743    /// │  Result 1: (doses₁, cost₁)             │
744    /// │                                         │
745    /// │  Optimization 2: Uniform Weights        │
746    /// │    (Population-based)                   │
747    /// │      ↓                                  │
748    /// │  Result 2: (doses₂, cost₂)             │
749    /// │                                         │
750    /// │  Select: min(cost₁, cost₂)             │
751    /// └────────────┬────────────────────────────┘
752    ///              ↓
753    /// ┌─────────────────────────────────────────┐
754    /// │ STAGE 3: Final Predictions              │
755    /// │                                         │
756    /// │  Calculate predictions with             │
757    /// │  optimal doses and winning weights      │
758    /// └─────────────────────────────────────────┘
759    /// ```
760    ///
761    /// # Returns
762    ///
763    /// `BestDoseResult` containing:
764    /// - `dose`: Optimal dose amount(s)
765    /// - `objf`: Final cost function value
766    /// - `preds`: Concentration-time predictions
767    /// - `auc_predictions`: AUC values (if target_type is AUC)
768    /// - `optimization_method`: "posterior" or "uniform"
769    pub fn optimize(self) -> Result<BestDoseResult> {
770        tracing::info!("╔══════════════════════════════════════════════════════════╗");
771        tracing::info!("║            BestDose Algorithm: STAGE 2 & 3               ║");
772        tracing::info!("║        Dual Optimization + Final Predictions             ║");
773        tracing::info!("╚══════════════════════════════════════════════════════════╝");
774
775        // STAGE 2 & 3: Dual optimization + predictions
776        optimization::dual_optimization(&self)
777    }
778
779    /// Set the bias weight (lambda parameter)
780    ///
781    /// - λ = 0.0 (default): Full personalization (minimize patient-specific variance)
782    /// - λ = 0.5: Balanced between individual and population
783    /// - λ = 1.0: Population-based (minimize deviation from population mean)
784    pub fn with_bias_weight(mut self, weight: f64) -> Self {
785        self.bias_weight = weight;
786        self
787    }
788
789    /// Get a reference to the refined posterior support points (Θ)
790    pub fn posterior_theta(&self) -> &Theta {
791        &self.theta
792    }
793
794    /// Get the posterior probability weights
795    pub fn posterior_weights(&self) -> &Weights {
796        &self.posterior
797    }
798
799    /// Get the filtered population weights used for the bias term
800    pub fn population_weights(&self) -> &Weights {
801        &self.population_weights
802    }
803
804    /// Get the prepared target subject
805    pub fn target_subject(&self) -> &Subject {
806        &self.target
807    }
808
809    /// Get the currently configured bias weight (λ)
810    pub fn bias_weight(&self) -> f64 {
811        self.bias_weight
812    }
813
814    /// Get the selected optimization target type
815    pub fn target_type(&self) -> Target {
816        self.target_type
817    }
818}