1use crate::event::MetricRecord;
8use crate::search::SearchSpace;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15pub enum Direction {
16 Minimize,
18 Maximize,
20}
21
22impl Direction {
23 pub fn normalize(self, value: f64) -> f64 {
27 match self {
28 Direction::Maximize => value,
29 Direction::Minimize => -value,
30 }
31 }
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Objective {
37 pub metric: String,
40 pub direction: Direction,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
46#[serde(tag = "scalarizer_type")]
47#[non_exhaustive]
48pub enum Scalarizer {
49 #[default]
51 WeightedSum,
52 AugmentedTchebycheff {
57 rho: f64,
60 },
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct CompositeObjective {
70 pub terms: Vec<(String, f64)>,
72 pub direction: Direction,
75 #[serde(default)]
79 pub scalarizer: Scalarizer,
80}
81
82impl CompositeObjective {
83 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#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(tag = "strategy_type")]
113pub enum SearchStrategy {
114 Grid {
116 points_per_dim: usize,
120 },
121
122 Random {
124 n_trials: usize,
126 seed: Option<u64>,
128 },
129
130 Bayesian {
132 n_trials: usize,
134 n_startup: usize,
137 seed: Option<u64>,
139 },
140
141 Hyperband {
145 max_resource: usize,
147 reduction_factor: usize,
150 },
151
152 MultiObjective {
156 n_trials: usize,
158 objectives: Vec<Objective>,
160 },
161}
162
163impl SearchStrategy {
164 pub fn n_trials(&self) -> Option<usize> {
166 match self {
167 Self::Grid { .. } => None, Self::Random { n_trials, .. } => Some(*n_trials),
169 Self::Bayesian { n_trials, .. } => Some(*n_trials),
170 Self::Hyperband { .. } => None, Self::MultiObjective { n_trials, .. } => Some(*n_trials),
172 }
173 }
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178#[serde(tag = "pruning_type")]
179pub enum PruningStrategy {
180 None,
182
183 Median {
185 n_warmup_steps: usize,
188 },
189
190 Percentile {
192 percentile: f64,
196 n_warmup_steps: usize,
199 },
200
201 Hyperband,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
209#[serde(tag = "trial_state")]
210pub enum TrialState {
211 Pending,
213 Running,
215 Completed,
218 Pruned {
221 step: usize,
223 reason: String,
225 },
226 Failed {
228 error: String,
230 },
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct Trial {
236 pub id: String,
239 pub params: HashMap<String, serde_json::Value>,
243 pub state: TrialState,
245 pub metrics: Vec<MetricRecord>,
248 pub duration_ms: Option<u64>,
250 #[serde(default)]
253 pub started_at: Option<DateTime<Utc>>,
254 #[serde(default)]
257 pub finished_at: Option<DateTime<Utc>>,
258}
259
260impl Trial {
261 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 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 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 pub fn is_complete(&self) -> bool {
303 matches!(self.state, TrialState::Completed)
304 }
305
306 pub fn is_terminal(&self) -> bool {
310 matches!(
311 self.state,
312 TrialState::Completed | TrialState::Pruned { .. } | TrialState::Failed { .. }
313 )
314 }
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct Study {
320 pub id: String,
322 pub name: String,
324 pub search_space: SearchSpace,
326 pub strategy: SearchStrategy,
328 pub pruning: PruningStrategy,
330 pub objectives: Vec<Objective>,
333 pub trials: Vec<Trial>,
336 pub frozen: HashMap<String, serde_json::Value>,
341 #[serde(default)]
345 pub seeds: Vec<i64>,
346 #[serde(default)]
349 pub composite: Option<CompositeObjective>,
350 #[serde(default)]
353 pub created_at: Option<DateTime<Utc>>,
354 #[serde(default)]
356 pub updated_at: Option<DateTime<Utc>>,
357 #[serde(default)]
359 pub tags: Vec<String>,
360 #[serde(default)]
362 pub git_sha: Option<String>,
363 #[serde(default)]
366 pub planned_trials: Option<usize>,
367}
368
369impl Study {
370 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 pub fn with_pruning(mut self, pruning: PruningStrategy) -> Self {
402 self.pruning = pruning;
403 self
404 }
405
406 pub fn with_composite(mut self, composite: CompositeObjective) -> Self {
410 self.composite = composite.into();
411 self
412 }
413
414 pub fn completed_trials(&self) -> Vec<&Trial> {
417 self.trials.iter().filter(|t| t.is_complete()).collect()
418 }
419
420 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 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 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 pub fn best_value(&self) -> Option<f64> {
461 self.best_trial().and_then(|t| self.objective_value(t))
462 }
463
464 pub fn total_trials(&self) -> Option<usize> {
467 self.planned_trials.or_else(|| self.strategy.n_trials())
468 }
469
470 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
480fn 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 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 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 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 #[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)); 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)); }
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 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 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); 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)); }
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 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 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 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}