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::{BestDosePosterior, 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//! #            settings: pmcore::routines::settings::Settings)
20//! #            -> anyhow::Result<()> {
21//! // Stage 1: Compute posterior from patient history
22//! let posterior = BestDosePosterior::compute(
23//!     &population_theta,               // Population support points from NPAG
24//!     &population_weights,             // Population probabilities
25//!     Some(past_data),                 // Patient history (None = use prior)
26//!     eq,                              // PK/PD model
27//!     settings,                        // NPAG settings
28//! )?;
29//!
30//! // Stage 2 & 3: Optimize doses and get predictions
31//! let result = posterior.optimize(
32//!     target,                          // Future template with targets
33//!     None,                            // time_offset (None = standard mode)
34//!     DoseRange::new(0.0, 1000.0),     // Dose constraints (0-1000 mg)
35//!     0.5,                             // bias_weight: 0=personalized, 1=population
36//!     Target::Concentration,           // Target type
37//! )?;
38//!
39//! // Extract results
40//! println!("Optimal dose: {:?} mg", result.doses());
41//! println!("Final cost: {}", result.objf());
42//! println!("Method: {}", result.optimization_method());
43//! # Ok(())
44//! # }
45//! ```
46//!
47//! # Algorithm Overview (Three Stages)
48//!
49//! ```text
50//! ┌─────────────────────────────────────────────────────────────────┐
51//! │ STAGE 1: Posterior Density Calculation                         │
52//! │                                                                 │
53//! │  Prior (N points)                                              │
54//! │      ↓                                                         │
55//! │  Step 1.1: NPAGFULL11 - Bayesian Filtering                    │
56//! │      Calculate P(data|θᵢ) for each support point              │
57//! │      Apply Bayes rule: P(θᵢ|data) ∝ P(data|θᵢ) × P(θᵢ)       │
58//! │      Filter: Keep points where P(θᵢ|data) > 1e-100 × max      │
59//! │      ↓                                                         │
60//! │  Filtered Posterior (M points, typically 5-50)                │
61//! │      ↓                                                         │
62//! │  Step 1.2: NPAGFULL - Local Refinement                        │
63//! │      For each filtered point:                                 │
64//! │          Run full NPAG optimization                           │
65//! │          Find refined "daughter" point                        │
66//! │      ↓                                                         │
67//! │  Refined Posterior (M points with NPAGFULL11 weights)         │
68//! └─────────────────────────────────────────────────────────────────┘
69//!
70//! ┌─────────────────────────────────────────────────────────────────┐
71//! │ STAGE 2: Dual Optimization                                     │
72//! │                                                                 │
73//! │  Optimization 1: Posterior Weights (Patient-Specific)          │
74//! │      Minimize Cost = (1-λ)×Variance + λ×Bias²                 │
75//! │      Using NPAGFULL11 posterior weights                        │
76//! │      ↓                                                         │
77//! │  Result 1: (doses₁, cost₁)                                     │
78//! │                                                                 │
79//! │  Optimization 2: Uniform Weights (Population)                  │
80//! │      Minimize Cost = (1-λ)×Variance + λ×Bias²                 │
81//! │      Using uniform weights (1/M for all points)                │
82//! │      ↓                                                         │
83//! │  Result 2: (doses₂, cost₂)                                     │
84//! │                                                                 │
85//! │  Select Best: min(cost₁, cost₂)                                │
86//! └─────────────────────────────────────────────────────────────────┘
87//!
88//! ┌─────────────────────────────────────────────────────────────────┐
89//! │ STAGE 3: Final Predictions                                     │
90//! │                                                                 │
91//! │  Calculate predictions with optimal doses                       │
92//! │  For AUC targets: Use dense time grid + trapezoidal rule      │
93//! │    - AUCFromZero: Cumulative from time 0                       │
94//! │    - AUCFromLastDose: Interval from last dose                  │
95//! │  Return: Optimal doses, cost, predictions, method used         │
96//! └─────────────────────────────────────────────────────────────────┘
97//! ```
98//!
99//! # Mathematical Foundation
100//!
101//! ## Bayesian Posterior
102//!
103//! The posterior density is calculated via Bayes' rule:
104//!
105//! ```text
106//! P(θ | data) = P(data | θ) × P(θ) / P(data)
107//! ```
108//!
109//! Where:
110//! - `P(θ | data)`: Posterior (patient-specific parameters)
111//! - `P(data | θ)`: Likelihood (from error model)
112//! - `P(θ)`: Prior (from population)
113//! - `P(data)`: Normalizing constant
114//!
115//! ## Cost Function
116//!
117//! The optimization minimizes a hybrid cost function:
118//!
119//! ```text
120//! Cost = (1-λ) × Variance + λ × Bias²
121//! ```
122//!
123//! **Variance Term** (Patient-Specific Performance):
124//! ```text
125//! Variance = Σᵢ P(θᵢ|data) × Σⱼ (target[j] - pred[i,j])²
126//! ```
127//! Expected squared error using posterior weights.
128//!
129//! **Bias Term** (Population-Level Performance):
130//! ```text
131//! Bias² = Σⱼ (target[j] - E[pred[j]])²
132//! where E[pred[j]] = Σᵢ P(θᵢ) × pred[i,j]
133//! ```
134//! Squared deviation from population mean prediction using prior weights.
135//!
136//! **Bias Weight Parameter (λ)**:
137//! - `λ = 0.0`: Fully personalized (minimize variance only)
138//! - `λ = 0.5`: Balanced hybrid approach
139//! - `λ = 1.0`: Population-based (minimize bias from population)
140//!
141//! # Examples
142//!
143//! ## Single Dose Optimization
144//!
145//! ```rust,no_run,ignore
146//! use pmcore::bestdose::{BestDosePosterior, Target, DoseRange};
147//! use pharmsol::prelude::Subject;
148//!
149//! # fn example(population_theta: pmcore::structs::theta::Theta,
150//! #            population_weights: pmcore::structs::weights::Weights,
151//! #            past: pharmsol::prelude::Subject,
152//! #            eq: pharmsol::prelude::ODE,
153//! #            settings: pmcore::routines::settings::Settings)
154//! #            -> anyhow::Result<()> {
155//! // Define target: 5 mg/L at 24 hours
156//! let target = Subject::builder("patient_001")
157//!     .bolus(0.0, 0.0, 0)             // Dose placeholder (will be optimized)
158//!     .observation(24.0, 5.0, 0)      // Target: 5 mg/L at 24h
159//!     .build();
160//!
161//! let posterior = BestDosePosterior::compute(
162//!     &population_theta, &population_weights, Some(past), eq, settings,
163//! )?;
164//!
165//! let result = posterior.optimize(
166//!     target, None,
167//!     DoseRange::new(10.0, 500.0),    // 10-500 mg allowed
168//!     0.3,                             // Slight population emphasis
169//!     Target::Concentration,
170//! )?;
171//!
172//! println!("Optimal dose: {} mg", result.doses()[0]);
173//! # Ok(())
174//! # }
175//! ```
176//!
177//! ## Multiple Doses with AUC Target
178//!
179//! ```rust,no_run,ignore
180//! use pmcore::bestdose::{BestDosePosterior, 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//! #            settings: pmcore::routines::settings::Settings)
188//! #            -> anyhow::Result<()> {
189//! // Target: Achieve AUC₂₄ = 400 mg·h/L
190//! let target = Subject::builder("patient_002")
191//!     .bolus(0.0, 0.0, 0)             // Dose 1 placeholder (optimized)
192//!     .bolus(12.0, 0.0, 0)            // Dose 2 placeholder (optimized)
193//!     .observation(24.0, 400.0, 0)    // Target: AUC₂₄ = 400
194//!     .build();
195//!
196//! let posterior = BestDosePosterior::compute(
197//!     &population_theta, &population_weights, Some(past), eq, settings,
198//! )?;
199//!
200//! let result = posterior.optimize(
201//!     target, None,
202//!     DoseRange::new(50.0, 300.0),
203//!     0.0,                             // Full personalization
204//!     Target::AUCFromZero,             // Cumulative AUC target!
205//! )?;
206//!
207//! println!("Dose 1: {} mg at t=0", result.doses()[0]);
208//! println!("Dose 2: {} mg at t=12", result.doses()[1]);
209//! if let Some(auc) = result.auc_predictions() {
210//!     println!("Predicted AUC₂₄: {} mg·h/L", auc[0].1);
211//! }
212//! # Ok(())
213//! # }
214//! ```
215//!
216//! ## Population-Only Optimization
217//!
218//! ```rust,no_run,ignore
219//! # use pmcore::bestdose::{BestDosePosterior, Target, DoseRange};
220//! # fn example(population_theta: pmcore::structs::theta::Theta,
221//! #            population_weights: pmcore::structs::weights::Weights,
222//! #            target: pharmsol::prelude::Subject,
223//! #            eq: pharmsol::prelude::ODE,
224//! #            settings: pmcore::routines::settings::Settings)
225//! #            -> anyhow::Result<()> {
226//! // No patient history - use population prior directly
227//! let posterior = BestDosePosterior::compute(
228//!     &population_theta, &population_weights,
229//!     None,                            // No past data → use prior
230//!     eq, settings,
231//! )?;
232//!
233//! let result = posterior.optimize(
234//!     target, None,
235//!     DoseRange::new(0.0, 1000.0),
236//!     1.0,                             // Full population weighting
237//!     Target::Concentration,
238//! )?;
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//! # See Also
265//!
266//! - [`BestDosePosterior`]: Two-stage API entry point (compute posterior, then optimize)
267//! - [`BestDoseResult`]: Output structure with optimal doses
268//! - [`Target`]: Enum for concentration vs AUC targets
269//! - [`DoseRange`]: Dose constraint specification
270
271pub(crate) mod cost;
272mod optimization;
273mod posterior;
274pub(crate) mod predictions;
275mod types;
276
277// Re-export public API
278pub use types::{
279    BestDosePosterior, BestDoseResult, BestDoseStatus, DoseRange, OptimalMethod, Target,
280};
281
282/// Helper function to concatenate past and future subjects (Option 3: Fortran MAKETMP approach)
283///
284/// This mimics Fortran's MAKETMP subroutine logic:
285/// 1. Takes doses (only doses, not observations) from past subject
286/// 2. Offsets all future subject event times by `effective_offset` (absolute)
287/// 3. Combines into single continuous subject
288///
289/// Note: This function receives the **effective** (absolute) offset, computed
290/// by `optimize()` as `max_past_time + time_offset` where `time_offset` is the
291/// user-facing gap parameter.
292///
293/// # Arguments
294///
295/// * `past` - Subject with past history (only doses will be used)
296/// * `future` - Subject template for future (all events: doses + observations)
297/// * `effective_offset` - Absolute time offset to apply to all future events
298///
299/// # Returns
300///
301/// Combined subject with:
302/// - Past doses at original times [0, effective_offset)
303/// - Future doses + observations at offset times [effective_offset, ∞)
304///
305/// # Example
306///
307/// ```rust,ignore
308/// // Past: dose at t=0, observation at t=6 (patient has been on therapy 6 hours)
309/// let past = Subject::builder("patient")
310///     .bolus(0.0, 500.0, 0)
311///     .observation(6.0, 15.0, 0)  // 15 mg/L at 6 hours (max_past_time = 6)
312///     .build();
313///
314/// // Future: dose at t=0 (relative), target at t=24 (relative)
315/// let future = Subject::builder("patient")
316///     .bolus(0.0, 100.0, 0)       // At absolute t=6 (with gap=0)
317///     .observation(24.0, 10.0, 0)  // At absolute t=30 (with gap=0)
318///     .build();
319///
320/// // effective_offset = max_past_time(6) + gap(0) = 6
321/// let combined = concatenate_past_and_future(&past, &future, 6.0);
322/// // Result: dose at t=0 (fixed, 500mg), dose at t=6 (optimizable),
323/// //         observation target at t=30 (10 mg/L)
324/// ```
325fn concatenate_past_and_future(
326    past: &pharmsol::prelude::Subject,
327    future: &pharmsol::prelude::Subject,
328    effective_offset: f64,
329) -> pharmsol::prelude::Subject {
330    use pharmsol::prelude::*;
331
332    let mut builder = Subject::builder(past.id());
333
334    // Add past doses only (skip observations from past)
335    for occasion in past.occasions() {
336        for event in occasion.events() {
337            match event {
338                Event::Bolus(bolus) => {
339                    builder = builder.bolus(bolus.time(), bolus.amount(), bolus.input());
340                }
341                Event::Infusion(inf) => {
342                    builder =
343                        builder.infusion(inf.time(), inf.amount(), inf.input(), inf.duration());
344                }
345                Event::Observation(_) => {
346                    // Skip observations from past (they were already used for posterior)
347                }
348            }
349        }
350    }
351
352    // Add future events with effective offset
353    for occasion in future.occasions() {
354        for event in occasion.events() {
355            match event {
356                Event::Bolus(bolus) => {
357                    builder = builder.bolus(
358                        bolus.time() + effective_offset,
359                        bolus.amount(),
360                        bolus.input(),
361                    );
362                }
363                Event::Infusion(inf) => {
364                    builder = builder.infusion(
365                        inf.time() + effective_offset,
366                        inf.amount(),
367                        inf.input(),
368                        inf.duration(),
369                    );
370                }
371                Event::Observation(obs) => {
372                    builder = match obs.value() {
373                        Some(val) => {
374                            builder.observation(obs.time() + effective_offset, val, obs.outeq())
375                        }
376                        None => builder,
377                    };
378                }
379            }
380        }
381    }
382
383    builder.build()
384}
385
386use anyhow::Result;
387use pharmsol::prelude::*;
388use pharmsol::ODE;
389
390use crate::routines::settings::Settings;
391use crate::structs::theta::Theta;
392use crate::structs::weights::Weights;
393
394use types::BestDoseProblem;
395
396// ═════════════════════════════════════════════════════════════════════════════
397// BestDosePosterior: Public two-stage API
398// ═════════════════════════════════════════════════════════════════════════════
399
400impl BestDosePosterior {
401    /// **Stage 1**: Compute the Bayesian posterior density from population prior and patient data
402    ///
403    /// This performs the expensive posterior calculation (NPAGFULL11 filtering + NPAGFULL refinement)
404    /// and returns a reusable `BestDosePosterior` that can be optimized multiple times.
405    ///
406    /// # Algorithm
407    ///
408    /// ```text
409    /// Prior (N support points)
410    ///     ↓
411    /// NPAGFULL11: Bayesian filtering
412    ///     P(θᵢ|data) ∝ P(data|θᵢ) × P(θᵢ)
413    ///     ↓
414    /// Filtered posterior (M points)
415    ///     ↓
416    /// NPAGFULL: Local refinement (max_cycles iterations)
417    ///     ↓
418    /// Refined posterior (M points with updated weights)
419    /// ```
420    ///
421    /// # Arguments
422    ///
423    /// * `population_theta` - Population support points from NPAG
424    /// * `population_weights` - Population probabilities
425    /// * `past_data` - Patient history (`None` = use prior directly)
426    /// * `eq` - Pharmacokinetic/pharmacodynamic model
427    /// * `settings` - NPAG settings (includes error models and posterior refinement config)
428    ///
429    /// # Example
430    ///
431    /// ```rust,no_run,ignore
432    /// let posterior = BestDosePosterior::compute(
433    ///     &theta, &weights,
434    ///     Some(past_subject),
435    ///     eq, settings,
436    /// )?;
437    /// println!("Posterior has {} support points", posterior.n_support_points());
438    /// ```
439    pub fn compute(
440        population_theta: &Theta,
441        population_weights: &Weights,
442        past_data: Option<Subject>,
443        eq: ODE,
444        settings: Settings,
445    ) -> Result<Self> {
446        tracing::info!("╔══════════════════════════════════════════════════════════╗");
447        tracing::info!("║            BestDose Algorithm: STAGE 1                   ║");
448        tracing::info!("║           Posterior Density Calculation                  ║");
449        tracing::info!("╚══════════════════════════════════════════════════════════╝");
450
451        let (posterior_theta, posterior_weights, filtered_population_weights, _past_subject) =
452            calculate_posterior_density(
453                population_theta,
454                population_weights,
455                past_data.as_ref(),
456                &eq,
457                &settings.errormodels,
458                &settings,
459            )?;
460
461        tracing::info!("╔══════════════════════════════════════════════════════════╗");
462        tracing::info!("║              Stage 1 Complete - Posterior Ready           ║");
463        tracing::info!("╚══════════════════════════════════════════════════════════╝");
464        tracing::info!("  Support points: {}", posterior_theta.matrix().nrows());
465
466        Ok(BestDosePosterior {
467            theta: posterior_theta,
468            posterior: posterior_weights,
469            population_weights: filtered_population_weights,
470            past_data,
471            eq,
472            settings,
473        })
474    }
475
476    /// **Stage 2**: Optimize doses for target outcomes using the computed posterior
477    ///
478    /// This runs the dual optimization (posterior weights vs uniform weights) and
479    /// returns the best dosing regimen. Can be called multiple times on the same
480    /// posterior with different parameters.
481    ///
482    /// # Arguments
483    ///
484    /// * `target` - Future dosing template with target observations
485    /// * `time_offset` - Optional gap (in hours) between the last past event and the start of
486    ///   the future target. 0 means the future starts immediately after the last past event.
487    ///   The effective absolute offset is `max_past_time + time_offset`.
488    /// * `dose_range` - Allowable dose constraints
489    /// * `bias_weight` - λ in \[0,1\]: 0=personalized, 1=population
490    /// * `target_type` - Concentration or AUC targets
491    ///
492    /// # Example
493    ///
494    /// ```rust,no_run,ignore
495    /// // Try different bias weights
496    /// for &bw in &[0.0, 0.25, 0.5, 0.75, 1.0] {
497    ///     let result = posterior.optimize(
498    ///         target.clone(),
499    ///         None,
500    ///         DoseRange::new(0.0, 300.0),
501    ///         bw,
502    ///         Target::Concentration,
503    ///     )?;
504    ///     println!("λ={}: dose={:.1}", bw, result.doses()[0]);
505    /// }
506    /// ```
507    pub fn optimize(
508        &self,
509        target: Subject,
510        time_offset: Option<f64>,
511        dose_range: DoseRange,
512        bias_weight: f64,
513        target_type: Target,
514    ) -> Result<BestDoseResult> {
515        tracing::info!("╔══════════════════════════════════════════════════════════╗");
516        tracing::info!("║            BestDose Algorithm: STAGE 2 & 3               ║");
517        tracing::info!("║        Dual Optimization + Final Predictions             ║");
518        tracing::info!("╚══════════════════════════════════════════════════════════╝");
519        tracing::info!("  Target type: {:?}", target_type);
520        tracing::info!("  Bias weight (λ): {}", bias_weight);
521
522        // Validate and compute effective time_offset
523        // time_offset is a gap relative to the last past event:
524        //   effective_offset = max_past_time + time_offset
525        // So time_offset=0 means "future starts right after last past event"
526        if let Some(t) = time_offset {
527            if t < 0.0 {
528                return Err(anyhow::anyhow!(
529                    "Invalid time_offset: {} is negative. \
530                    time_offset must be >= 0 (it represents the gap after the last past event).",
531                    t
532                ));
533            }
534        }
535
536        // Compute the absolute offset for concatenation
537        let effective_offset = time_offset.map(|t| {
538            let max_past_time = self
539                .past_data
540                .as_ref()
541                .map(|past| {
542                    past.occasions()
543                        .iter()
544                        .flat_map(|occ| occ.events())
545                        .map(|event| match event {
546                            Event::Bolus(b) => b.time(),
547                            Event::Infusion(i) => i.time(),
548                            Event::Observation(o) => o.time(),
549                        })
550                        .fold(0.0_f64, |max, time| max.max(time))
551                })
552                .unwrap_or(0.0);
553            max_past_time + t
554        });
555
556        // Handle past/future concatenation if needed
557        // When time_offset is provided, offset all target event times by the
558        // effective offset (max_past_time + gap) and prepend past doses.
559        let final_target = match effective_offset {
560            None => target,
561            Some(eff) => {
562                tracing::info!(
563                    "  Time offset gap: {:?} hours (effective absolute offset: {} hours)",
564                    time_offset,
565                    eff
566                );
567                match &self.past_data {
568                    Some(past) => {
569                        tracing::info!("  Concatenating past doses with offset target events");
570                        concatenate_past_and_future(past, &target, eff)
571                    }
572                    None => {
573                        tracing::info!("  No past data stored — offsetting target events only");
574                        concatenate_past_and_future(
575                            &Subject::builder("empty").build(),
576                            &target,
577                            eff,
578                        )
579                    }
580                }
581            }
582        };
583
584        // Validate that the target has observations
585        let has_observations = final_target
586            .occasions()
587            .iter()
588            .flat_map(|occ| occ.events())
589            .any(|event| matches!(event, Event::Observation(_)));
590        if !has_observations {
591            return Err(anyhow::anyhow!(
592                "Target subject has no observations. At least one observation is required for dose optimization."
593            ));
594        }
595
596        // Build the internal optimization problem
597        let problem = BestDoseProblem {
598            target: final_target,
599            target_type,
600            population_weights: self.population_weights.clone(),
601            theta: self.theta.clone(),
602            posterior: self.posterior.clone(),
603            eq: self.eq.clone(),
604            settings: self.settings.clone(),
605            doserange: dose_range,
606            bias_weight,
607        };
608
609        // Run dual optimization + final predictions
610        optimization::dual_optimization(&problem)
611    }
612}
613
614// ═════════════════════════════════════════════════════════════════════════════
615// Helper Functions for STAGE 1: Posterior Density Calculation
616// ═════════════════════════════════════════════════════════════════════════════
617
618/// Calculate posterior density (STAGE 1: Two-step process)
619///
620/// # Algorithm Flow (Matches Diagram)
621///
622/// ```text
623/// Prior Density (N points)
624///     ↓
625/// Has past data with observations?
626///     ↓ Yes              ↓ No
627/// Step 1.1:          Use prior
628/// NPAGFULL11         directly
629/// (Bayesian Filter)
630///     ↓
631/// Filtered Posterior (M points)
632///     ↓
633/// Step 1.2:
634/// NPAGFULL
635/// (Refine each point)
636///     ↓
637/// Refined Posterior
638/// (M points with NPAGFULL11 weights)
639/// ```
640///
641/// # Returns
642///
643/// Tuple: (posterior_theta, posterior_weights, filtered_population_weights, past_subject)
644fn calculate_posterior_density(
645    population_theta: &Theta,
646    population_weights: &Weights,
647    past_data: Option<&Subject>,
648    eq: &ODE,
649    error_models: &AssayErrorModels,
650    settings: &Settings,
651) -> Result<(Theta, Weights, Weights, Subject)> {
652    match past_data {
653        None => {
654            tracing::info!("  No past data → using prior directly");
655            Ok((
656                population_theta.clone(),
657                population_weights.clone(),
658                population_weights.clone(),
659                Subject::builder("Empty").build(),
660            ))
661        }
662        Some(past_subject) => {
663            // Check if past data has observations
664            let has_observations = !past_subject.occasions().is_empty()
665                && past_subject.occasions().iter().any(|occ| {
666                    occ.events()
667                        .iter()
668                        .any(|e| matches!(e, Event::Observation(_)))
669                });
670
671            if !has_observations {
672                tracing::info!("  Past data has no observations → using prior directly");
673                Ok((
674                    population_theta.clone(),
675                    population_weights.clone(),
676                    population_weights.clone(),
677                    past_subject.clone(),
678                ))
679            } else {
680                // Two-step posterior calculation
681                tracing::info!("  Past data with observations → calculating two-step posterior");
682                tracing::info!("    Step 1.1: NPAGFULL11 (Bayesian filtering)");
683                tracing::info!("    Step 1.2: NPAGFULL (local refinement)");
684
685                let past_data_obj = Data::new(vec![past_subject.clone()]);
686
687                let (posterior_theta, posterior_weights, filtered_population_weights) =
688                    posterior::calculate_two_step_posterior(
689                        population_theta,
690                        population_weights,
691                        &past_data_obj,
692                        eq,
693                        error_models,
694                        settings,
695                    )?;
696
697                Ok((
698                    posterior_theta,
699                    posterior_weights,
700                    filtered_population_weights,
701                    past_subject.clone(),
702                ))
703            }
704        }
705    }
706}