Skip to main content

somatize_core/
study.rs

1//! Study — defines an optimization experiment with objectives and strategy.
2//!
3//! A [`Study`] holds the search space, strategy (Grid/Random/Bayesian),
4//! objectives, and tracks trials. The `StudyRunner` in soma-runtime
5//! orchestrates execution.
6
7use crate::event::MetricRecord;
8use crate::search::SearchSpace;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13/// Direction of optimization for an objective.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15pub enum Direction {
16    /// Lower values are better (losses, error rates).
17    Minimize,
18    /// Higher values are better (accuracy, F1).
19    Maximize,
20}
21
22impl Direction {
23    /// Map a value onto a maximize scale: identity for `Maximize`,
24    /// negation for `Minimize`. Lets samplers and pruners assume
25    /// "higher is better" throughout.
26    pub fn normalize(self, value: f64) -> f64 {
27        match self {
28            Direction::Maximize => value,
29            Direction::Minimize => -value,
30        }
31    }
32}
33
34/// An optimization objective (metric + direction).
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Objective {
37    /// Name of the metric to optimize, matched against each trial's
38    /// recorded [`MetricRecord`]s.
39    pub metric: String,
40    /// Whether lower or higher values of the metric win.
41    pub direction: Direction,
42}
43
44/// How a [`CompositeObjective`] combines its weighted terms.
45#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
46#[serde(tag = "scalarizer_type")]
47#[non_exhaustive]
48pub enum Scalarizer {
49    /// `Σ wᵢ·vᵢ` — the plain weighted sum.
50    #[default]
51    WeightedSum,
52    /// Augmented weighted min/max (Tchebycheff-style, Knowles 2006):
53    /// emphasizes the worst-performing term so non-convex trade-offs
54    /// aren't missed. For `Maximize`: `minᵢ(wᵢ·vᵢ) + ρ·Σ wᵢ·vᵢ`;
55    /// for `Minimize` the `min` becomes a `max`.
56    AugmentedTchebycheff {
57        /// Weight of the augmenting sum term. `0.0` is pure worst-case;
58        /// small values (~0.05–0.1) keep the sum as a tie-breaker.
59        rho: f64,
60    },
61}
62
63/// A scalar objective composed from several named metrics.
64///
65/// The composite is the single value the optimizer sees; the component
66/// metrics stay recorded on each trial, so a Pareto/multi-objective
67/// layer can be added later without schema migration.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct CompositeObjective {
70    /// `(metric_name, weight)` pairs. Negative weights penalize.
71    pub terms: Vec<(String, f64)>,
72    /// Direction of the *composite* value. Overrides any per-objective
73    /// direction: see [`Study::primary_direction`].
74    pub direction: Direction,
75    /// How the weighted terms collapse into one scalar. Defaults to
76    /// [`Scalarizer::WeightedSum`]; absent in pre-scalarizer JSON, hence
77    /// `serde(default)`.
78    #[serde(default)]
79    pub scalarizer: Scalarizer,
80}
81
82impl CompositeObjective {
83    /// Evaluate over a trial's final (last-recorded) metric values.
84    /// `None` if any term's metric is missing.
85    pub fn evaluate(&self, trial: &Trial) -> Option<f64> {
86        let weighted: Vec<f64> = self
87            .terms
88            .iter()
89            .map(|(name, weight)| trial.last_metric(name).map(|v| weight * v))
90            .collect::<Option<Vec<f64>>>()?;
91        if weighted.is_empty() {
92            return None;
93        }
94        let sum: f64 = weighted.iter().sum();
95        Some(match self.scalarizer {
96            Scalarizer::WeightedSum => sum,
97            Scalarizer::AugmentedTchebycheff { rho } => {
98                let worst = match self.direction {
99                    Direction::Maximize => weighted.iter().cloned().fold(f64::INFINITY, f64::min),
100                    Direction::Minimize => {
101                        weighted.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
102                    }
103                };
104                worst + rho * sum
105            }
106        })
107    }
108}
109
110/// Search strategy for hyperparameter optimization.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(tag = "strategy_type")]
113pub enum SearchStrategy {
114    /// Exhaustive grid search.
115    Grid {
116        /// Grid resolution per continuous dimension (categoricals use
117        /// all their choices), so total trials grow multiplicatively
118        /// with dimension count.
119        points_per_dim: usize,
120    },
121
122    /// Random sampling.
123    Random {
124        /// Number of configurations to sample.
125        n_trials: usize,
126        /// RNG seed for reproducible sampling; `None` derives one.
127        seed: Option<u64>,
128    },
129
130    /// Bayesian optimization (TPE).
131    Bayesian {
132        /// Total number of trials, startup included.
133        n_trials: usize,
134        /// Trials sampled randomly before the TPE model takes over
135        /// (it needs history to split good from bad).
136        n_startup: usize,
137        /// RNG seed for reproducible sampling; `None` derives one.
138        seed: Option<u64>,
139    },
140
141    /// Successive halving with early stopping. Declared for forward
142    /// compatibility: no sampler implements it yet, and Python's
143    /// `Study.run` rejects it as unsupported.
144    Hyperband {
145        /// Budget (e.g. epochs) a surviving trial may consume.
146        max_resource: usize,
147        /// Fraction of trials kept per halving round (`3` keeps one
148        /// in three).
149        reduction_factor: usize,
150    },
151
152    /// Multi-objective optimization. Declared for forward
153    /// compatibility: no sampler implements it yet — for multiple
154    /// metrics today, scalarize via [`CompositeObjective`].
155    MultiObjective {
156        /// Number of configurations to sample.
157        n_trials: usize,
158        /// The objectives to trade off against each other.
159        objectives: Vec<Objective>,
160    },
161}
162
163impl SearchStrategy {
164    /// Planned number of trials (if known).
165    pub fn n_trials(&self) -> Option<usize> {
166        match self {
167            Self::Grid { .. } => None, // depends on search space
168            Self::Random { n_trials, .. } => Some(*n_trials),
169            Self::Bayesian { n_trials, .. } => Some(*n_trials),
170            Self::Hyperband { .. } => None, // depends on brackets
171            Self::MultiObjective { n_trials, .. } => Some(*n_trials),
172        }
173    }
174}
175
176/// Pruning strategy for early stopping of unpromising trials.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(tag = "pruning_type")]
179pub enum PruningStrategy {
180    /// No pruning.
181    None,
182
183    /// Prune if metric is below median of completed trials at same step.
184    Median {
185        /// Steps a trial runs unconditionally before pruning checks
186        /// begin — early metrics are too noisy to kill on.
187        n_warmup_steps: usize,
188    },
189
190    /// Prune if metric is below given percentile.
191    Percentile {
192        /// Percentile (0–100) of completed trials' values at the same
193        /// step the trial must reach to survive. `50.0` is
194        /// [`PruningStrategy::Median`].
195        percentile: f64,
196        /// Steps a trial runs unconditionally before pruning checks
197        /// begin.
198        n_warmup_steps: usize,
199    },
200
201    /// Bracket-based pruning (used with Hyperband). Declared for
202    /// forward compatibility: the `StudyRunner` currently builds no
203    /// pruner for it, so it behaves like [`PruningStrategy::None`].
204    Hyperband,
205}
206
207/// State of a single trial.
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
209#[serde(tag = "trial_state")]
210pub enum TrialState {
211    /// Created but not yet started (the state [`Trial::new`] assigns).
212    Pending,
213    /// Currently executing.
214    Running,
215    /// Finished normally — the only state [`Study::objective_value`]
216    /// scores.
217    Completed,
218    /// Stopped early by the pruner. Terminal but not a failure: a
219    /// pruned trial's metrics stay recorded.
220    Pruned {
221        /// The step at which the pruner intervened.
222        step: usize,
223        /// Human-readable pruning verdict (e.g. value vs. median).
224        reason: String,
225    },
226    /// Errored during execution.
227    Failed {
228        /// The error message that terminated the trial.
229        error: String,
230    },
231}
232
233/// A single hyperparameter evaluation.
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct Trial {
236    /// Identifier unique within the study (the runner uses
237    /// `trial_NNNN`).
238    pub id: String,
239    /// The full configuration this trial ran with: sampled dimension
240    /// values (prefixed names like `"SVM.C"`), plus the study's frozen
241    /// params and a `"seed"` entry when [`Study::seeds`] is non-empty.
242    pub params: HashMap<String, serde_json::Value>,
243    /// Lifecycle state; see [`TrialState`].
244    pub state: TrialState,
245    /// Every metric recorded during the trial, in recording order —
246    /// multiple values per name across steps are expected.
247    pub metrics: Vec<MetricRecord>,
248    /// Wall-clock duration, set when the trial reaches a terminal state.
249    pub duration_ms: Option<u64>,
250    /// When execution started. `serde(default)`: absent in trials
251    /// serialized before timestamps existed.
252    #[serde(default)]
253    pub started_at: Option<DateTime<Utc>>,
254    /// When the trial reached a terminal state. `serde(default)` for
255    /// the same pre-timestamp JSON.
256    #[serde(default)]
257    pub finished_at: Option<DateTime<Utc>>,
258}
259
260impl Trial {
261    /// A fresh [`TrialState::Pending`] trial for a sampled
262    /// configuration: no metrics, no timestamps. The runner flips it
263    /// to `Running` and stamps `started_at` when execution begins.
264    pub fn new(id: impl Into<String>, params: HashMap<String, serde_json::Value>) -> Self {
265        Self {
266            id: id.into(),
267            params,
268            state: TrialState::Pending,
269            metrics: Vec::new(),
270            duration_ms: None,
271            started_at: None,
272            finished_at: None,
273        }
274    }
275
276    /// Last recorded value for a metric (its final value).
277    pub fn last_metric(&self, name: &str) -> Option<f64> {
278        self.metrics
279            .iter()
280            .filter(|m| m.name == name)
281            .map(|m| m.value)
282            .next_back()
283    }
284
285    /// Get the best recorded value for a specific metric.
286    pub fn best_metric(&self, name: &str, direction: Direction) -> Option<f64> {
287        let values: Vec<f64> = self
288            .metrics
289            .iter()
290            .filter(|m| m.name == name)
291            .map(|m| m.value)
292            .collect();
293        match direction {
294            Direction::Maximize => values.into_iter().reduce(f64::max),
295            Direction::Minimize => values.into_iter().reduce(f64::min),
296        }
297    }
298
299    /// `true` only for [`TrialState::Completed`] — pruned and failed
300    /// trials are finished but not complete. This is the filter
301    /// [`Study::completed_trials`] and pruner histories use.
302    pub fn is_complete(&self) -> bool {
303        matches!(self.state, TrialState::Completed)
304    }
305
306    /// `true` once the trial can no longer change state: `Completed`,
307    /// `Pruned`, or `Failed`. This is what [`Study::progress`] counts,
308    /// so pruned and failed trials still advance the progress bar.
309    pub fn is_terminal(&self) -> bool {
310        matches!(
311            self.state,
312            TrialState::Completed | TrialState::Pruned { .. } | TrialState::Failed { .. }
313        )
314    }
315}
316
317/// An optimization study: orchestrates multiple trials.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct Study {
320    /// Unique identifier, generated by [`Study::new`].
321    pub id: String,
322    /// Human-readable name; needs no uniqueness.
323    pub name: String,
324    /// The dimensions trials are sampled from.
325    pub search_space: SearchSpace,
326    /// How configurations are chosen (grid, random, TPE, ...).
327    pub strategy: SearchStrategy,
328    /// Early-stopping policy for unpromising trials; `None` by default.
329    pub pruning: PruningStrategy,
330    /// Declared objectives. Only the first is scored today (see
331    /// [`Study::objective_value`]), unless `composite` overrides it.
332    pub objectives: Vec<Objective>,
333    /// Every trial the study has run, in start order — terminal and
334    /// in-flight alike.
335    pub trials: Vec<Trial>,
336    /// Study-level fixed parameters, injected into every trial's
337    /// params by the runner (same mechanism as
338    /// [`SearchSpace::freeze`](crate::search::SearchSpace::freeze),
339    /// but settable after the space was built).
340    pub frozen: HashMap<String, serde_json::Value>,
341    /// Experiment seeds: when non-empty, every sampled configuration is
342    /// evaluated once per seed (trial params carry `"seed"`), giving
343    /// each seed an independent cache line and resumable trial.
344    #[serde(default)]
345    pub seeds: Vec<i64>,
346    /// Scalar objective composed from several metrics; takes precedence
347    /// over `objectives` when set.
348    #[serde(default)]
349    pub composite: Option<CompositeObjective>,
350    /// When the study was created. `serde(default)`: absent in
351    /// pre-timestamp JSON.
352    #[serde(default)]
353    pub created_at: Option<DateTime<Utc>>,
354    /// When the study was last saved/modified.
355    #[serde(default)]
356    pub updated_at: Option<DateTime<Utc>>,
357    /// Free-form labels for filtering studies in listings.
358    #[serde(default)]
359    pub tags: Vec<String>,
360    /// Git commit the study ran at, for reproducibility bookkeeping.
361    #[serde(default)]
362    pub git_sha: Option<String>,
363    /// Total trials resolved by the sampler at run start (grid sizes
364    /// are unknown until the search space is prepared).
365    #[serde(default)]
366    pub planned_trials: Option<usize>,
367}
368
369impl Study {
370    /// A fresh study with a generated `id` and `created_at` stamped
371    /// now: no trials, no pruning ([`PruningStrategy::None`]), no
372    /// composite objective. Layer options on with the `with_*`
373    /// builders.
374    pub fn new(
375        name: impl Into<String>,
376        search_space: SearchSpace,
377        strategy: SearchStrategy,
378        objectives: Vec<Objective>,
379    ) -> Self {
380        Self {
381            id: uuid_v4(),
382            name: name.into(),
383            search_space,
384            strategy,
385            pruning: PruningStrategy::None,
386            objectives,
387            trials: Vec::new(),
388            frozen: HashMap::new(),
389            seeds: Vec::new(),
390            composite: None,
391            created_at: Some(Utc::now()),
392            updated_at: None,
393            tags: Vec::new(),
394            git_sha: None,
395            planned_trials: None,
396        }
397    }
398
399    /// Builder: replace the pruning strategy (the default from
400    /// [`Study::new`] is [`PruningStrategy::None`]).
401    pub fn with_pruning(mut self, pruning: PruningStrategy) -> Self {
402        self.pruning = pruning;
403        self
404    }
405
406    /// Builder: set the composite objective. Once set it becomes the
407    /// value the optimizer sees, overriding `objectives` for scoring
408    /// and direction — see [`Study::objective_value`].
409    pub fn with_composite(mut self, composite: CompositeObjective) -> Self {
410        self.composite = composite.into();
411        self
412    }
413
414    /// Trials in [`TrialState::Completed`] — the population pruners
415    /// compare against. Pruned and failed trials are excluded.
416    pub fn completed_trials(&self) -> Vec<&Trial> {
417        self.trials.iter().filter(|t| t.is_complete()).collect()
418    }
419
420    /// Direction of the effective objective (composite if set, else the
421    /// first declared objective).
422    pub fn primary_direction(&self) -> Option<Direction> {
423        self.composite
424            .as_ref()
425            .map(|c| c.direction)
426            .or_else(|| self.objectives.first().map(|o| o.direction))
427    }
428
429    /// The single source of truth for scoring a trial: the composite
430    /// objective if set, else the best value of the first objective's
431    /// metric. `None` for incomplete trials or missing metrics.
432    pub fn objective_value(&self, trial: &Trial) -> Option<f64> {
433        if !trial.is_complete() {
434            return None;
435        }
436        if let Some(composite) = &self.composite {
437            return composite.evaluate(trial);
438        }
439        let obj = self.objectives.first()?;
440        trial.best_metric(&obj.metric, obj.direction)
441    }
442
443    /// Get the best trial for the effective objective.
444    pub fn best_trial(&self) -> Option<&Trial> {
445        let direction = self.primary_direction()?;
446        self.trials
447            .iter()
448            .filter_map(|t| Some((t, self.objective_value(t)?)))
449            .reduce(|best, current| {
450                if direction.normalize(current.1) > direction.normalize(best.1) {
451                    current
452                } else {
453                    best
454                }
455            })
456            .map(|(t, _)| t)
457    }
458
459    /// Objective value of the best trial.
460    pub fn best_value(&self) -> Option<f64> {
461        self.best_trial().and_then(|t| self.objective_value(t))
462    }
463
464    /// Number of total planned trials (if known). Prefers the count
465    /// the sampler resolved at run start (covers grid strategies).
466    pub fn total_trials(&self) -> Option<usize> {
467        self.planned_trials.or_else(|| self.strategy.n_trials())
468    }
469
470    /// Fraction of trials completed.
471    pub fn progress(&self) -> f64 {
472        let completed = self.trials.iter().filter(|t| t.is_terminal()).count();
473        match self.total_trials() {
474            Some(total) if total > 0 => completed as f64 / total as f64,
475            _ => 0.0,
476        }
477    }
478}
479
480// Reading and writing a study to disk is I/O, so it lives in the runtime
481// as `somatize_runtime::study_io::StudyIo`. Import that trait and
482// `study.save(path)` / `Study::load(path)` read the same as they always
483// did. See design/decisions.
484
485fn uuid_v4() -> String {
486    use std::time::{SystemTime, UNIX_EPOCH};
487    let nanos = SystemTime::now()
488        .duration_since(UNIX_EPOCH)
489        .unwrap_or_default()
490        .as_nanos();
491    format!("study_{nanos:x}")
492}
493
494#[cfg(test)]
495mod tests {
496
497    #[test]
498    fn pre_composite_study_json_still_loads() {
499        // A study serialized before composite/timestamps/tags existed.
500        let old = serde_json::json!({
501            "id": "study_abc",
502            "name": "legacy",
503            "search_space": {"dimensions": [], "frozen": {}},
504            "strategy": {"strategy_type": "Random", "n_trials": 5, "seed": null},
505            "pruning": {"pruning_type": "None"},
506            "objectives": [{"metric": "f1", "direction": "Maximize"}],
507            "trials": [{
508                "id": "t1",
509                "params": {"lr": 0.01},
510                "state": {"trial_state": "Completed"},
511                "metrics": [],
512                "duration_ms": 12
513            }],
514            "frozen": {}
515        });
516        let study: Study = serde_json::from_value(old).unwrap();
517        assert_eq!(study.name, "legacy");
518        assert!(study.composite.is_none());
519        assert!(study.created_at.is_none());
520        assert!(study.trials[0].started_at.is_none());
521        assert!(study.planned_trials.is_none());
522    }
523    use super::*;
524    use crate::search::{Scale, SearchDimension};
525    use chrono::Utc;
526    use serde_json::json;
527
528    fn sample_search_space() -> SearchSpace {
529        let mut space = SearchSpace::new();
530        space.add(SearchDimension::Float {
531            name: "lr".into(),
532            low: 0.001,
533            high: 0.1,
534            scale: Scale::Log,
535            default: None,
536        });
537        space.add(SearchDimension::Categorical {
538            name: "kernel".into(),
539            choices: vec![json!("rbf"), json!("linear")],
540        });
541        space
542    }
543
544    fn make_trial(id: &str, f1: f64) -> Trial {
545        let mut t = Trial::new(id, HashMap::from([("lr".into(), json!(0.01))]));
546        t.state = TrialState::Completed;
547        t.metrics.push(MetricRecord {
548            name: "f1".into(),
549            value: f1,
550            step: 10,
551            timestamp: Utc::now(),
552        });
553        t
554    }
555
556    #[test]
557    fn study_best_trial_maximize() {
558        let mut study = Study::new(
559            "test",
560            sample_search_space(),
561            SearchStrategy::Random {
562                n_trials: 10,
563                seed: None,
564            },
565            vec![Objective {
566                metric: "f1".into(),
567                direction: Direction::Maximize,
568            }],
569        );
570
571        study.trials.push(make_trial("t1", 0.75));
572        study.trials.push(make_trial("t2", 0.90));
573        study.trials.push(make_trial("t3", 0.82));
574
575        let best = study.best_trial().unwrap();
576        assert_eq!(best.id, "t2");
577    }
578
579    #[test]
580    fn study_best_trial_minimize() {
581        let mut study = Study::new(
582            "test",
583            sample_search_space(),
584            SearchStrategy::Random {
585                n_trials: 10,
586                seed: None,
587            },
588            vec![Objective {
589                metric: "loss".into(),
590                direction: Direction::Minimize,
591            }],
592        );
593
594        let mut t1 = Trial::new("t1", HashMap::new());
595        t1.state = TrialState::Completed;
596        t1.metrics.push(MetricRecord {
597            name: "loss".into(),
598            value: 0.5,
599            step: 10,
600            timestamp: Utc::now(),
601        });
602
603        let mut t2 = Trial::new("t2", HashMap::new());
604        t2.state = TrialState::Completed;
605        t2.metrics.push(MetricRecord {
606            name: "loss".into(),
607            value: 0.3,
608            step: 10,
609            timestamp: Utc::now(),
610        });
611
612        study.trials.push(t1);
613        study.trials.push(t2);
614
615        let best = study.best_trial().unwrap();
616        assert_eq!(best.id, "t2");
617    }
618
619    #[test]
620    fn study_progress() {
621        let mut study = Study::new(
622            "test",
623            sample_search_space(),
624            SearchStrategy::Random {
625                n_trials: 10,
626                seed: None,
627            },
628            vec![],
629        );
630
631        assert_eq!(study.progress(), 0.0);
632
633        study.trials.push(make_trial("t1", 0.5));
634        study.trials.push(make_trial("t2", 0.6));
635        assert!((study.progress() - 0.2).abs() < f64::EPSILON);
636    }
637
638    #[test]
639    fn trial_terminal_states() {
640        let mut t = Trial::new("t1", HashMap::new());
641        assert!(!t.is_terminal());
642
643        t.state = TrialState::Running;
644        assert!(!t.is_terminal());
645
646        t.state = TrialState::Completed;
647        assert!(t.is_terminal());
648
649        t.state = TrialState::Pruned {
650            step: 5,
651            reason: "bad".into(),
652        };
653        assert!(t.is_terminal());
654
655        t.state = TrialState::Failed {
656            error: "oops".into(),
657        };
658        assert!(t.is_terminal());
659    }
660
661    #[test]
662    fn study_serde_roundtrip() {
663        let mut study = Study::new(
664            "test_study",
665            sample_search_space(),
666            SearchStrategy::Bayesian {
667                n_trials: 100,
668                n_startup: 10,
669                seed: Some(42),
670            },
671            vec![Objective {
672                metric: "f1".into(),
673                direction: Direction::Maximize,
674            }],
675        );
676        study.trials.push(make_trial("t1", 0.85));
677
678        let json = serde_json::to_string(&study).unwrap();
679        let deserialized: Study = serde_json::from_str(&json).unwrap();
680        assert_eq!(deserialized.name, "test_study");
681        assert_eq!(deserialized.trials.len(), 1);
682    }
683
684    #[test]
685    fn search_strategy_n_trials() {
686        assert_eq!(
687            SearchStrategy::Random {
688                n_trials: 50,
689                seed: None
690            }
691            .n_trials(),
692            Some(50)
693        );
694        assert_eq!(SearchStrategy::Grid { points_per_dim: 5 }.n_trials(), None);
695        assert_eq!(
696            SearchStrategy::Bayesian {
697                n_trials: 100,
698                n_startup: 10,
699                seed: None
700            }
701            .n_trials(),
702            Some(100)
703        );
704    }
705
706    fn multi_metric_trial(id: &str, f1: f64, gap: f64) -> Trial {
707        let mut t = make_trial(id, f1);
708        t.metrics.push(MetricRecord {
709            name: "gap".into(),
710            value: gap,
711            step: 10,
712            timestamp: Utc::now(),
713        });
714        t
715    }
716
717    #[test]
718    fn composite_weighted_sum_picks_best() {
719        let mut study = Study::new(
720            "composite",
721            sample_search_space(),
722            SearchStrategy::Random {
723                n_trials: 3,
724                seed: None,
725            },
726            vec![],
727        )
728        .with_composite(CompositeObjective {
729            terms: vec![("f1".into(), 0.7), ("gap".into(), -0.3)],
730            direction: Direction::Maximize,
731            scalarizer: Scalarizer::WeightedSum,
732        });
733
734        // t1: 0.7*0.9 - 0.3*0.5 = 0.48   t2: 0.7*0.8 - 0.3*0.05 = 0.545
735        study.trials.push(multi_metric_trial("t1", 0.9, 0.5));
736        study.trials.push(multi_metric_trial("t2", 0.8, 0.05));
737
738        assert_eq!(study.best_trial().unwrap().id, "t2");
739        let v = study.objective_value(&study.trials[1]).unwrap();
740        assert!((v - 0.545).abs() < 1e-9);
741    }
742
743    #[test]
744    fn composite_missing_metric_is_none() {
745        let study = Study::new(
746            "c",
747            SearchSpace::new(),
748            SearchStrategy::Random {
749                n_trials: 1,
750                seed: None,
751            },
752            vec![],
753        )
754        .with_composite(CompositeObjective {
755            terms: vec![("f1".into(), 1.0), ("missing".into(), 1.0)],
756            direction: Direction::Maximize,
757            scalarizer: Scalarizer::WeightedSum,
758        });
759        let t = make_trial("t1", 0.9);
760        assert!(study.objective_value(&t).is_none());
761    }
762
763    #[test]
764    fn composite_tchebycheff_penalizes_worst_term() {
765        let composite = CompositeObjective {
766            terms: vec![("f1".into(), 1.0), ("gap".into(), 1.0)],
767            direction: Direction::Maximize,
768            scalarizer: Scalarizer::AugmentedTchebycheff { rho: 0.1 },
769        };
770        // Balanced (0.5, 0.5) should beat lopsided (0.9, 0.1):
771        // balanced: min=0.5 + 0.1*1.0 = 0.6; lopsided: min=0.1 + 0.1*1.0 = 0.2
772        let balanced = multi_metric_trial("b", 0.5, 0.5);
773        let lopsided = multi_metric_trial("l", 0.9, 0.1);
774        assert!(composite.evaluate(&balanced).unwrap() > composite.evaluate(&lopsided).unwrap());
775    }
776
777    #[test]
778    fn direction_normalize() {
779        assert_eq!(Direction::Maximize.normalize(0.5), 0.5);
780        assert_eq!(Direction::Minimize.normalize(0.5), -0.5);
781    }
782
783    fn rising_falling_trial(id: &str, name: &str, values: &[f64]) -> Trial {
784        let mut t = Trial::new(id, HashMap::new());
785        t.state = TrialState::Completed;
786        for (step, v) in values.iter().enumerate() {
787            t.metrics.push(MetricRecord {
788                name: name.into(),
789                value: *v,
790                step,
791                timestamp: Utc::now(),
792            });
793        }
794        t
795    }
796
797    #[test]
798    fn last_metric_is_last_not_best() {
799        let t = rising_falling_trial("t", "f1", &[0.5, 0.9, 0.4]);
800        assert_eq!(t.last_metric("f1"), Some(0.4));
801        assert_eq!(t.best_metric("f1", Direction::Maximize), Some(0.9));
802        assert_eq!(t.best_metric("f1", Direction::Minimize), Some(0.4));
803        assert_eq!(t.last_metric("missing"), None);
804    }
805
806    /// CONTRACT: `objective_value` scores single-objective studies on
807    /// the BEST value across steps, but composite studies on the LAST
808    /// (final) value of each term. The same rising-then-falling curve
809    /// therefore scores differently depending on which mode is active.
810    #[test]
811    fn objective_value_best_vs_last_divergence_is_pinned() {
812        let t = rising_falling_trial("t", "f1", &[0.5, 0.9, 0.4]);
813
814        let single = Study::new(
815            "single",
816            SearchSpace::new(),
817            SearchStrategy::Random {
818                n_trials: 1,
819                seed: None,
820            },
821            vec![Objective {
822                metric: "f1".into(),
823                direction: Direction::Maximize,
824            }],
825        );
826        assert_eq!(single.objective_value(&t), Some(0.9)); // best
827
828        let composite = Study::new(
829            "composite",
830            SearchSpace::new(),
831            SearchStrategy::Random {
832                n_trials: 1,
833                seed: None,
834            },
835            vec![],
836        )
837        .with_composite(CompositeObjective {
838            terms: vec![("f1".into(), 1.0)],
839            direction: Direction::Maximize,
840            scalarizer: Scalarizer::WeightedSum,
841        });
842        assert_eq!(composite.objective_value(&t), Some(0.4)); // last
843    }
844
845    #[test]
846    fn objective_value_none_for_non_completed_trials() {
847        let study = Study::new(
848            "s",
849            SearchSpace::new(),
850            SearchStrategy::Random {
851                n_trials: 1,
852                seed: None,
853            },
854            vec![Objective {
855                metric: "f1".into(),
856                direction: Direction::Maximize,
857            }],
858        );
859        for state in [
860            TrialState::Pending,
861            TrialState::Running,
862            TrialState::Pruned {
863                step: 1,
864                reason: "bad".into(),
865            },
866            TrialState::Failed {
867                error: "boom".into(),
868            },
869        ] {
870            let mut t = make_trial("t", 0.9);
871            t.state = state;
872            assert!(study.objective_value(&t).is_none());
873        }
874    }
875
876    #[test]
877    fn composite_empty_terms_is_none() {
878        for scalarizer in [
879            Scalarizer::WeightedSum,
880            Scalarizer::AugmentedTchebycheff { rho: 0.1 },
881        ] {
882            let composite = CompositeObjective {
883                terms: vec![],
884                direction: Direction::Maximize,
885                scalarizer,
886            };
887            assert!(composite.evaluate(&make_trial("t", 0.9)).is_none());
888        }
889    }
890
891    #[test]
892    fn composite_tchebycheff_minimize_penalizes_worst_loss() {
893        // On a minimize scale the WORST term is the largest one.
894        let composite = CompositeObjective {
895            terms: vec![("loss_a".into(), 1.0), ("loss_b".into(), 1.0)],
896            direction: Direction::Minimize,
897            scalarizer: Scalarizer::AugmentedTchebycheff { rho: 0.1 },
898        };
899        let balanced = {
900            let mut t = rising_falling_trial("b", "loss_a", &[0.5]);
901            t.metrics.push(MetricRecord {
902                name: "loss_b".into(),
903                value: 0.5,
904                step: 0,
905                timestamp: Utc::now(),
906            });
907            t
908        };
909        let lopsided = {
910            let mut t = rising_falling_trial("l", "loss_a", &[0.1]);
911            t.metrics.push(MetricRecord {
912                name: "loss_b".into(),
913                value: 0.9,
914                step: 0,
915                timestamp: Utc::now(),
916            });
917            t
918        };
919        // balanced: max=0.5 + 0.1*1.0 = 0.6; lopsided: max=0.9 + 0.1*1.0 = 1.0.
920        // Lower is better under Minimize → balanced wins.
921        let b = composite.evaluate(&balanced).unwrap();
922        let l = composite.evaluate(&lopsided).unwrap();
923        assert!(
924            b < l,
925            "balanced {b} must beat lopsided {l} on a minimize scale"
926        );
927    }
928
929    #[test]
930    fn composite_tchebycheff_rho_zero_is_pure_worst_case() {
931        let composite = CompositeObjective {
932            terms: vec![("a".into(), 1.0), ("b".into(), 1.0)],
933            direction: Direction::Maximize,
934            scalarizer: Scalarizer::AugmentedTchebycheff { rho: 0.0 },
935        };
936        let t = multi_metric_trial("t", 0.9, 0.2); // f1=0.9, gap=0.2 — wrong names
937        let mut t2 = Trial::new("t2", HashMap::new());
938        t2.state = TrialState::Completed;
939        for (name, v) in [("a", 0.9), ("b", 0.2)] {
940            t2.metrics.push(MetricRecord {
941                name: name.into(),
942                value: v,
943                step: 0,
944                timestamp: Utc::now(),
945            });
946        }
947        let _ = t;
948        assert_eq!(composite.evaluate(&t2), Some(0.2)); // min of the terms
949    }
950
951    #[test]
952    fn scalarizer_serde_roundtrip_and_default() {
953        let study = Study::new(
954            "s",
955            SearchSpace::new(),
956            SearchStrategy::Random {
957                n_trials: 1,
958                seed: None,
959            },
960            vec![],
961        )
962        .with_composite(CompositeObjective {
963            terms: vec![("f1".into(), 0.7)],
964            direction: Direction::Maximize,
965            scalarizer: Scalarizer::AugmentedTchebycheff { rho: 0.25 },
966        });
967        let json = serde_json::to_string(&study).unwrap();
968        let back: Study = serde_json::from_str(&json).unwrap();
969        match back.composite.unwrap().scalarizer {
970            Scalarizer::AugmentedTchebycheff { rho } => assert_eq!(rho, 0.25),
971            other => panic!("wrong scalarizer: {other:?}"),
972        }
973
974        // Explicit WeightedSum tag and a missing scalarizer field both
975        // resolve to the default.
976        let explicit: Scalarizer =
977            serde_json::from_value(serde_json::json!({"scalarizer_type": "WeightedSum"})).unwrap();
978        assert_eq!(explicit, Scalarizer::WeightedSum);
979        let composite: CompositeObjective = serde_json::from_value(serde_json::json!({
980            "terms": [["f1", 1.0]],
981            "direction": "Maximize",
982        }))
983        .unwrap();
984        assert_eq!(composite.scalarizer, Scalarizer::default());
985    }
986
987    #[test]
988    fn primary_direction_composite_wins_over_objectives() {
989        let study = Study::new(
990            "s",
991            SearchSpace::new(),
992            SearchStrategy::Random {
993                n_trials: 1,
994                seed: None,
995            },
996            vec![Objective {
997                metric: "loss".into(),
998                direction: Direction::Minimize,
999            }],
1000        )
1001        .with_composite(CompositeObjective {
1002            terms: vec![("f1".into(), 1.0)],
1003            direction: Direction::Maximize,
1004            scalarizer: Scalarizer::WeightedSum,
1005        });
1006        assert_eq!(study.primary_direction(), Some(Direction::Maximize));
1007    }
1008
1009    #[test]
1010    fn best_value_matches_best_trial_and_handles_empty() {
1011        let mut study = Study::new(
1012            "s",
1013            sample_search_space(),
1014            SearchStrategy::Random {
1015                n_trials: 2,
1016                seed: None,
1017            },
1018            vec![Objective {
1019                metric: "f1".into(),
1020                direction: Direction::Maximize,
1021            }],
1022        );
1023        assert!(study.best_value().is_none());
1024        study.trials.push(make_trial("t1", 0.7));
1025        study.trials.push(make_trial("t2", 0.9));
1026        assert_eq!(study.best_value(), Some(0.9));
1027
1028        // All-failed study: no best.
1029        let mut failed = make_trial("t3", 1.0);
1030        failed.state = TrialState::Failed { error: "x".into() };
1031        let mut all_failed = study.clone();
1032        all_failed.trials = vec![failed];
1033        assert!(all_failed.best_trial().is_none());
1034        assert!(all_failed.best_value().is_none());
1035    }
1036
1037    #[test]
1038    fn best_trial_tie_keeps_first() {
1039        let mut study = Study::new(
1040            "s",
1041            sample_search_space(),
1042            SearchStrategy::Random {
1043                n_trials: 2,
1044                seed: None,
1045            },
1046            vec![Objective {
1047                metric: "f1".into(),
1048                direction: Direction::Maximize,
1049            }],
1050        );
1051        study.trials.push(make_trial("first", 0.8));
1052        study.trials.push(make_trial("second", 0.8));
1053        assert_eq!(study.best_trial().unwrap().id, "first");
1054    }
1055
1056    #[test]
1057    fn planned_trials_governs_total_and_progress() {
1058        let mut study = Study::new(
1059            "grid",
1060            sample_search_space(),
1061            SearchStrategy::Grid { points_per_dim: 3 },
1062            vec![],
1063        );
1064        // Grid size is unknown until a sampler resolves it.
1065        assert_eq!(study.total_trials(), None);
1066        assert_eq!(study.progress(), 0.0);
1067
1068        study.planned_trials = Some(6);
1069        study.trials.push(make_trial("t1", 0.5));
1070        study.trials.push(make_trial("t2", 0.5));
1071        study.trials.push(make_trial("t3", 0.5));
1072        assert_eq!(study.total_trials(), Some(6));
1073        assert!((study.progress() - 0.5).abs() < f64::EPSILON);
1074    }
1075
1076    #[test]
1077    fn no_best_trial_when_empty() {
1078        let study = Study::new(
1079            "empty",
1080            SearchSpace::new(),
1081            SearchStrategy::Random {
1082                n_trials: 10,
1083                seed: None,
1084            },
1085            vec![Objective {
1086                metric: "f1".into(),
1087                direction: Direction::Maximize,
1088            }],
1089        );
1090        assert!(study.best_trial().is_none());
1091    }
1092}