1use std::collections::BTreeMap;
9use std::fmt;
10use std::io;
11use std::path::{Component, Path, PathBuf};
12use std::time::Duration;
13
14use serde::de::{self, Deserializer, Visitor};
15use serde::ser::Serializer;
16use serde::{Deserialize, Serialize};
17
18use henad_core::explore::design::DesignKind;
19use henad_core::explore::factor::{FactorSpec, FactorTarget, LevelSpec};
20use henad_core::explore::fingerprint::fnv1a64;
21use henad_core::explore::reducer::{ReducerError, ReducerSpec};
22use henad_core::explore::search::genetic::GeneticSettings;
23use henad_core::explore::search::hill_climb::HillClimbSettings;
24use henad_core::explore::search::pse::{PatternAxis, PatternSpaceSettings};
25use henad_core::explore::search::{Aggregate, Goal, Objective, SearchAlgorithm, SearchSpec};
26use henad_core::explore::seed::SeedScheme;
27use henad_core::explore::spec::{ActionSpec, BlockSpec, MeasureSettings, RunSettings, SeedSettings, SweepSpec};
28use henad_core::explore::stop::{StopError, StopSpec};
29
30use crate::exec::Concurrency;
31use crate::sweep::SpecSource;
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct SpecFile {
37 pub model: String,
39 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
41 pub set: BTreeMap<String, SpecValue>,
42 #[serde(default)]
44 pub run: RunTable,
45 #[serde(default)]
47 pub measure: MeasureTable,
48 #[serde(default)]
50 pub seeds: SeedsTable,
51 #[serde(default, rename = "action", skip_serializing_if = "Vec::is_empty")]
53 pub actions: Vec<ActionTable>,
54 #[serde(default, rename = "block", skip_serializing_if = "Vec::is_empty")]
56 pub blocks: Vec<BlockTable>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub search: Option<SearchTable>,
60 #[serde(default, skip_serializing_if = "ExecutionTable::is_default")]
62 pub execution: ExecutionTable,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields, default)]
68pub struct RunTable {
69 pub steps: u64,
71 pub warmup: u64,
73 pub replicates: u64,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub stop: Option<StopTable>,
78 #[serde(skip_serializing_if = "Option::is_none")]
81 pub timeout_s: Option<f64>,
82}
83
84impl Default for RunTable {
85 fn default() -> Self {
86 (&RunSettings::default()).into()
87 }
88}
89
90impl From<&RunSettings> for RunTable {
91 fn from(run: &RunSettings) -> Self {
92 Self {
93 steps: run.steps,
94 warmup: run.warmup,
95 replicates: run.replicates,
96 stop: run.stop.as_ref().map(|stop| StopTable {
97 condition: stop.to_string(),
98 min_tick: stop.min_tick,
99 }),
100 timeout_s: run.timeout.map(|timeout| timeout.as_secs_f64()),
101 }
102 }
103}
104
105impl RunTable {
106 fn into_settings(self) -> Result<RunSettings, SpecFileError> {
113 let stop = self
114 .stop
115 .map(|stop| StopSpec::parse(&stop.condition, stop.min_tick))
116 .transpose()
117 .map_err(SpecFileError::Stop)?;
118 let timeout = self
119 .timeout_s
120 .map(|seconds| {
121 Duration::try_from_secs_f64(seconds)
122 .ok()
123 .ok_or(SpecFileError::Timeout { seconds })
124 })
125 .transpose()?;
126 Ok(RunSettings {
127 steps: self.steps,
128 warmup: self.warmup,
129 replicates: self.replicates,
130 stop,
131 timeout,
132 })
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct StopTable {
140 pub condition: String,
142 #[serde(default)]
144 pub min_tick: u64,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(deny_unknown_fields)]
150pub struct ActionTable {
151 pub id: String,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub name: Option<String>,
156 pub tick: u64,
158}
159
160impl From<&ActionSpec> for ActionTable {
161 fn from(action: &ActionSpec) -> Self {
162 Self {
163 id: action.id.clone(),
164 name: (action.name != action.id).then(|| action.name.clone()),
165 tick: action.tick,
166 }
167 }
168}
169
170impl From<ActionTable> for ActionSpec {
171 fn from(action: ActionTable) -> Self {
172 Self {
173 name: action.name.unwrap_or_else(|| action.id.clone()),
174 id: action.id,
175 tick: action.tick,
176 }
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(deny_unknown_fields, default)]
183pub struct MeasureTable {
184 pub stats_every: u64,
186 #[serde(skip_serializing_if = "Option::is_none")]
188 pub series_every: Option<u64>,
189 pub default_reducers: bool,
191 #[serde(skip_serializing_if = "Vec::is_empty")]
193 pub reducers: Vec<ReducerTable>,
194}
195
196impl Default for MeasureTable {
197 fn default() -> Self {
198 let measure = MeasureSettings::default();
199 Self {
200 stats_every: measure.stats_every,
201 series_every: None,
202 default_reducers: measure.default_reducers,
203 reducers: Vec::new(),
204 }
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct ReducerTable {
212 pub column: String,
214 pub kinds: Vec<String>,
216}
217
218#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(deny_unknown_fields, default)]
221pub struct SeedsTable {
222 pub root: TomlSeed,
224 pub scheme: SeedSchemeFile,
226}
227
228#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
230pub struct TomlSeed(pub u64);
231
232impl Serialize for TomlSeed {
233 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
234 match i64::try_from(self.0) {
235 Ok(seed) => serializer.serialize_i64(seed),
236 Err(_) => serializer.serialize_str(&self.0.to_string()),
237 }
238 }
239}
240
241impl<'de> Deserialize<'de> for TomlSeed {
242 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
243 struct SeedVisitor;
244
245 impl Visitor<'_> for SeedVisitor {
246 type Value = TomlSeed;
247
248 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
249 formatter.write_str("a seed from 0 to 18446744073709551615, as an integer or a decimal string")
250 }
251
252 fn visit_i64<E: de::Error>(self, seed: i64) -> Result<TomlSeed, E> {
253 u64::try_from(seed)
254 .ok()
255 .map(TomlSeed)
256 .ok_or_else(|| E::invalid_value(de::Unexpected::Signed(seed), &self))
257 }
258
259 fn visit_u64<E: de::Error>(self, seed: u64) -> Result<TomlSeed, E> {
260 Ok(TomlSeed(seed))
261 }
262
263 fn visit_str<E: de::Error>(self, seed: &str) -> Result<TomlSeed, E> {
264 seed.parse()
265 .ok()
266 .map(TomlSeed)
267 .ok_or_else(|| E::invalid_value(de::Unexpected::Str(seed), &self))
268 }
269 }
270
271 deserializer.deserialize_any(SeedVisitor)
272 }
273}
274
275#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "snake_case")]
278pub enum SeedSchemeFile {
279 #[default]
281 Common,
282 Independent,
284}
285
286impl From<SeedScheme> for SeedSchemeFile {
287 fn from(scheme: SeedScheme) -> Self {
288 match scheme {
289 SeedScheme::Common => Self::Common,
290 SeedScheme::Independent => Self::Independent,
291 }
292 }
293}
294
295impl From<SeedSchemeFile> for SeedScheme {
296 fn from(scheme: SeedSchemeFile) -> Self {
297 match scheme {
298 SeedSchemeFile::Common => Self::Common,
299 SeedSchemeFile::Independent => Self::Independent,
300 }
301 }
302}
303
304#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
306#[serde(deny_unknown_fields)]
307pub struct BlockTable {
308 #[serde(default)]
310 pub design: DesignKindFile,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub samples: Option<usize>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub design_seed: Option<TomlSeed>,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub file: Option<PathBuf>,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub table_text: Option<String>,
323 #[serde(default)]
325 pub factors: Vec<FactorTable>,
326 #[serde(skip)]
328 pub file_text: Option<String>,
329}
330
331#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct DesignTableFile {
334 pub path: PathBuf,
336 pub fnv1a64: u64,
338}
339
340#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
342#[serde(rename_all = "snake_case")]
343pub enum DesignKindFile {
344 #[default]
346 Factorial,
347 Zip,
349 Random,
351 Lhs,
353 Table,
355}
356
357impl From<&DesignKind> for DesignKindFile {
358 fn from(design: &DesignKind) -> Self {
359 match design {
360 DesignKind::Factorial => Self::Factorial,
361 DesignKind::Zip => Self::Zip,
362 DesignKind::Random { .. } => Self::Random,
363 DesignKind::LatinHypercube { .. } => Self::Lhs,
364 DesignKind::Table { .. } => Self::Table,
365 }
366 }
367}
368
369#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
371#[serde(deny_unknown_fields)]
372pub struct FactorTable {
373 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub param: Option<String>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub action: Option<String>,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub values: Option<Vec<SpecValue>>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub range: Option<RangeTable>,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub levels: Option<LevelsKeyword>,
388}
389
390#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
392#[serde(deny_unknown_fields)]
393pub struct RangeTable {
394 pub min: f64,
396 pub max: f64,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub step: Option<f64>,
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
408#[serde(rename_all = "snake_case")]
409pub enum LevelsKeyword {
410 All,
412}
413
414enum FactorTableError {
416 Target,
418 Levels { target_name: String },
420}
421
422impl FactorTable {
423 fn into_spec(self) -> Result<FactorSpec, FactorTableError> {
424 let target = match (self.param, self.action) {
425 (Some(id), None) => FactorTarget::Param(id),
426 (None, Some(name)) => FactorTarget::Action(name),
427 _ => return Err(FactorTableError::Target),
428 };
429 let levels = match (self.values, self.range, self.levels) {
430 (Some(values), None, None) => LevelSpec::Values(values.iter().map(SpecValue::to_text).collect()),
431 (None, Some(range), None) => LevelSpec::Range {
432 min: range.min,
433 max: range.max,
434 step: range.step,
435 },
436 (None, None, Some(LevelsKeyword::All)) => LevelSpec::All,
437 _ => {
438 let (FactorTarget::Param(target_name) | FactorTarget::Action(target_name)) = target;
439 return Err(FactorTableError::Levels { target_name });
440 }
441 };
442 Ok(FactorSpec { target, levels })
443 }
444}
445
446impl From<&FactorSpec> for FactorTable {
447 fn from(factor: &FactorSpec) -> Self {
449 let (param, action) = match &factor.target {
450 FactorTarget::Param(id) => (Some(id.clone()), None),
451 FactorTarget::Action(name) => (None, Some(name.clone())),
452 };
453 let mut table = Self {
454 param,
455 action,
456 values: None,
457 range: None,
458 levels: None,
459 };
460 match &factor.levels {
461 LevelSpec::Values(values) => {
462 table.values = Some(values.iter().map(|value| SpecValue::from_text(value)).collect());
463 }
464 &LevelSpec::Range { min, max, step } => table.range = Some(RangeTable { min, max, step }),
465 LevelSpec::All => table.levels = Some(LevelsKeyword::All),
466 }
467 table
468 }
469}
470
471#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
477#[serde(deny_unknown_fields)]
478pub struct SearchTable {
479 pub algorithm: SearchAlgorithmFile,
481 pub max_evaluations: u64,
483 pub batch_size: usize,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub objective: Option<ObjectiveTable>,
488 pub space: Vec<FactorTable>,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub hill_climb: Option<HillClimbTable>,
493 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub genetic: Option<GeneticTable>,
496 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub pse: Option<PatternSpaceTable>,
499}
500
501#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
503#[serde(rename_all = "snake_case")]
504pub enum SearchAlgorithmFile {
505 Random,
507 HillClimb,
509 Genetic,
511 Pse,
513}
514
515#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
517#[serde(deny_unknown_fields)]
518pub struct ObjectiveTable {
519 pub column: String,
521 pub goal: GoalFile,
523 #[serde(default)]
525 pub aggregate: AggregateFile,
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
530#[serde(rename_all = "snake_case")]
531pub enum GoalFile {
532 Minimize,
534 Maximize,
536}
537
538#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
540#[serde(rename_all = "snake_case")]
541pub enum AggregateFile {
542 Mean,
544 #[default]
546 Median,
547}
548
549#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
551#[serde(deny_unknown_fields, default)]
552pub struct HillClimbTable {
553 pub mutation_scale: f64,
555 pub patience: u64,
557 pub reevaluate: bool,
559}
560
561impl Default for HillClimbTable {
562 fn default() -> Self {
563 (&HillClimbSettings::default()).into()
564 }
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
569#[serde(deny_unknown_fields, default)]
570pub struct GeneticTable {
571 pub population: usize,
573 pub elite_count: usize,
575 pub tournament_size: usize,
577 pub crossover_rate: f64,
579 pub mutation_rate: f64,
581 pub mutation_scale: f64,
583 pub reevaluate_fraction: f64,
585}
586
587impl Default for GeneticTable {
588 fn default() -> Self {
589 (&GeneticSettings::default()).into()
590 }
591}
592
593#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
597#[serde(deny_unknown_fields)]
598pub struct PatternSpaceTable {
599 pub x_axis: PatternAxisTable,
601 pub y_axis: PatternAxisTable,
603 #[serde(default = "default_initial_samples")]
605 pub initial_samples: u64,
606 #[serde(default = "default_pattern_mutation_scale")]
608 pub mutation_scale: f64,
609 #[serde(default)]
611 pub aggregate: AggregateFile,
612}
613
614#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
617#[serde(deny_unknown_fields)]
618pub struct PatternAxisTable {
619 pub column: String,
621 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub min: Option<f64>,
624 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub max: Option<f64>,
627 pub cells: u32,
629}
630
631fn default_pattern_settings() -> PatternSpaceSettings {
632 let axis = PatternAxis::automatic(String::new(), 1);
633 PatternSpaceSettings::new(axis.clone(), axis)
634}
635
636fn default_initial_samples() -> u64 {
637 default_pattern_settings().initial_samples
638}
639
640fn default_pattern_mutation_scale() -> f64 {
641 default_pattern_settings().mutation_scale
642}
643
644impl From<&HillClimbSettings> for HillClimbTable {
645 fn from(settings: &HillClimbSettings) -> Self {
646 Self {
647 mutation_scale: settings.mutation_scale,
648 patience: settings.patience,
649 reevaluate: settings.reevaluate,
650 }
651 }
652}
653
654impl From<HillClimbTable> for HillClimbSettings {
655 fn from(table: HillClimbTable) -> Self {
656 Self {
657 mutation_scale: table.mutation_scale,
658 patience: table.patience,
659 reevaluate: table.reevaluate,
660 }
661 }
662}
663
664impl From<&GeneticSettings> for GeneticTable {
665 fn from(settings: &GeneticSettings) -> Self {
666 Self {
667 population: settings.population,
668 elite_count: settings.elite_count,
669 tournament_size: settings.tournament_size,
670 crossover_rate: settings.crossover_rate,
671 mutation_rate: settings.mutation_rate,
672 mutation_scale: settings.mutation_scale,
673 reevaluate_fraction: settings.reevaluate_fraction,
674 }
675 }
676}
677
678impl From<GeneticTable> for GeneticSettings {
679 fn from(table: GeneticTable) -> Self {
680 Self {
681 population: table.population,
682 elite_count: table.elite_count,
683 tournament_size: table.tournament_size,
684 crossover_rate: table.crossover_rate,
685 mutation_rate: table.mutation_rate,
686 mutation_scale: table.mutation_scale,
687 reevaluate_fraction: table.reevaluate_fraction,
688 }
689 }
690}
691
692impl From<&PatternAxis> for PatternAxisTable {
693 fn from(axis: &PatternAxis) -> Self {
694 Self {
695 column: axis.column.clone(),
696 min: axis.min,
697 max: axis.max,
698 cells: axis.cells,
699 }
700 }
701}
702
703impl PatternAxisTable {
704 fn into_axis(self, lone_bound: &'static str) -> Result<PatternAxis, SpecFileError> {
710 if self.min.is_some() != self.max.is_some() {
711 return Err(SpecFileError::Search { reason: lone_bound });
712 }
713 Ok(PatternAxis {
714 column: self.column,
715 min: self.min,
716 max: self.max,
717 cells: self.cells,
718 })
719 }
720}
721
722impl From<&PatternSpaceSettings> for PatternSpaceTable {
723 fn from(settings: &PatternSpaceSettings) -> Self {
724 Self {
725 x_axis: (&settings.x_axis).into(),
726 y_axis: (&settings.y_axis).into(),
727 initial_samples: settings.initial_samples,
728 mutation_scale: settings.mutation_scale,
729 aggregate: settings.aggregate.into(),
730 }
731 }
732}
733
734impl PatternSpaceTable {
735 fn into_settings(self) -> Result<PatternSpaceSettings, SpecFileError> {
741 Ok(PatternSpaceSettings {
742 x_axis: self
743 .x_axis
744 .into_axis("x_axis needs both min and max, or neither for an automatic range")?,
745 y_axis: self
746 .y_axis
747 .into_axis("y_axis needs both min and max, or neither for an automatic range")?,
748 initial_samples: self.initial_samples,
749 mutation_scale: self.mutation_scale,
750 aggregate: self.aggregate.into(),
751 })
752 }
753}
754
755impl From<Goal> for GoalFile {
756 fn from(goal: Goal) -> Self {
757 match goal {
758 Goal::Minimize => Self::Minimize,
759 Goal::Maximize => Self::Maximize,
760 }
761 }
762}
763
764impl From<GoalFile> for Goal {
765 fn from(goal: GoalFile) -> Self {
766 match goal {
767 GoalFile::Minimize => Self::Minimize,
768 GoalFile::Maximize => Self::Maximize,
769 }
770 }
771}
772
773impl From<Aggregate> for AggregateFile {
774 fn from(aggregate: Aggregate) -> Self {
775 match aggregate {
776 Aggregate::Mean => Self::Mean,
777 Aggregate::Median => Self::Median,
778 }
779 }
780}
781
782impl From<AggregateFile> for Aggregate {
783 fn from(aggregate: AggregateFile) -> Self {
784 match aggregate {
785 AggregateFile::Mean => Self::Mean,
786 AggregateFile::Median => Self::Median,
787 }
788 }
789}
790
791impl SearchTable {
792 fn into_spec(self) -> Result<SearchSpec, SpecFileError> {
800 let refuse = |reason| Err(SpecFileError::Search { reason });
801 if self.hill_climb.is_some() && self.algorithm != SearchAlgorithmFile::HillClimb {
802 return refuse("[search.hill_climb] applies to a hill_climb search");
803 }
804 if self.genetic.is_some() && self.algorithm != SearchAlgorithmFile::Genetic {
805 return refuse("[search.genetic] applies to a genetic search");
806 }
807 if self.pse.is_some() && self.algorithm != SearchAlgorithmFile::Pse {
808 return refuse("[search.pse] applies to a pse search");
809 }
810 let algorithm = match self.algorithm {
811 SearchAlgorithmFile::Random => SearchAlgorithm::Random,
812 SearchAlgorithmFile::HillClimb => SearchAlgorithm::HillClimb(self.hill_climb.unwrap_or_default().into()),
813 SearchAlgorithmFile::Genetic => SearchAlgorithm::Genetic(self.genetic.unwrap_or_default().into()),
814 SearchAlgorithmFile::Pse => match self.pse {
815 Some(settings) => SearchAlgorithm::PatternSpaceExploration(settings.into_settings()?),
816 None => return refuse("a pse search needs a [search.pse] table with x_axis and y_axis"),
817 },
818 };
819 let space = self
820 .space
821 .into_iter()
822 .map(|factor| {
823 factor.into_spec().map_err(|error| match error {
824 FactorTableError::Target => SpecFileError::SearchFactorTarget,
825 FactorTableError::Levels { target_name } => SpecFileError::SearchFactorLevels { target_name },
826 })
827 })
828 .collect::<Result<_, _>>()?;
829 Ok(SearchSpec {
830 algorithm,
831 max_evaluations: self.max_evaluations,
832 batch_size: self.batch_size,
833 objective: self.objective.map(|objective| Objective {
834 column: objective.column,
835 goal: objective.goal.into(),
836 aggregate: objective.aggregate.into(),
837 }),
838 space,
839 })
840 }
841}
842
843impl From<&SearchSpec> for SearchTable {
844 fn from(search: &SearchSpec) -> Self {
846 let mut table = Self {
847 algorithm: SearchAlgorithmFile::Random,
848 max_evaluations: search.max_evaluations,
849 batch_size: search.batch_size,
850 objective: search.objective.as_ref().map(|objective| ObjectiveTable {
851 column: objective.column.clone(),
852 goal: objective.goal.into(),
853 aggregate: objective.aggregate.into(),
854 }),
855 space: search.space.iter().map(FactorTable::from).collect(),
856 hill_climb: None,
857 genetic: None,
858 pse: None,
859 };
860 match &search.algorithm {
861 SearchAlgorithm::Random => {}
862 SearchAlgorithm::HillClimb(settings) => {
863 table.algorithm = SearchAlgorithmFile::HillClimb;
864 table.hill_climb = Some(settings.into());
865 }
866 SearchAlgorithm::Genetic(settings) => {
867 table.algorithm = SearchAlgorithmFile::Genetic;
868 table.genetic = Some(settings.into());
869 }
870 SearchAlgorithm::PatternSpaceExploration(settings) => {
871 table.algorithm = SearchAlgorithmFile::Pse;
872 table.pse = Some(settings.into());
873 }
874 }
875 table
876 }
877}
878
879#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
881#[serde(deny_unknown_fields, default)]
882pub struct ExecutionTable {
883 #[serde(with = "concurrent_field")]
885 pub concurrent: Concurrency,
886 #[serde(skip_serializing_if = "Option::is_none")]
888 pub memory: Option<u64>,
889 #[serde(skip_serializing_if = "Option::is_none")]
891 pub gpu_memory: Option<u64>,
892}
893
894impl ExecutionTable {
895 fn is_default(&self) -> bool {
896 *self == Self::default()
897 }
898}
899
900mod concurrent_field {
902 use std::fmt;
903 use std::num::NonZeroUsize;
904
905 use serde::de::{self, Deserializer, Visitor};
906 use serde::ser::Serializer;
907
908 use crate::exec::Concurrency;
909
910 pub(super) fn serialize<S: Serializer>(concurrency: &Concurrency, serializer: S) -> Result<S::Ok, S::Error> {
911 match concurrency {
912 Concurrency::Auto => serializer.serialize_str("auto"),
913 Concurrency::Fixed(count) => serializer.serialize_u64(count.get() as u64),
914 }
915 }
916
917 pub(super) fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Concurrency, D::Error> {
918 struct ConcurrencyVisitor;
919
920 impl Visitor<'_> for ConcurrencyVisitor {
921 type Value = Concurrency;
922
923 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
924 formatter.write_str("\"auto\" or a count of at least 1")
925 }
926
927 fn visit_i64<E: de::Error>(self, count: i64) -> Result<Concurrency, E> {
928 usize::try_from(count)
929 .ok()
930 .and_then(NonZeroUsize::new)
931 .map(Concurrency::Fixed)
932 .ok_or_else(|| E::invalid_value(de::Unexpected::Signed(count), &self))
933 }
934
935 fn visit_u64<E: de::Error>(self, count: u64) -> Result<Concurrency, E> {
936 usize::try_from(count)
937 .ok()
938 .and_then(NonZeroUsize::new)
939 .map(Concurrency::Fixed)
940 .ok_or_else(|| E::invalid_value(de::Unexpected::Unsigned(count), &self))
941 }
942
943 fn visit_str<E: de::Error>(self, raw: &str) -> Result<Concurrency, E> {
944 raw.parse()
945 .ok()
946 .ok_or_else(|| E::invalid_value(de::Unexpected::Str(raw), &self))
947 }
948 }
949
950 deserializer.deserialize_any(ConcurrencyVisitor)
951 }
952}
953
954#[derive(Debug, Clone, PartialEq)]
956pub enum SpecValue {
957 Integer(i64),
959 Float(f64),
961 Bool(bool),
963 Text(String),
965}
966
967impl SpecValue {
968 pub fn to_text(&self) -> String {
972 match self {
973 Self::Integer(number) => number.to_string(),
974 Self::Float(number) => number.to_string(),
975 Self::Bool(flag) => flag.to_string(),
976 Self::Text(text) => text.clone(),
977 }
978 }
979
980 pub fn from_text(text: &str) -> Self {
985 if let Ok(number) = text.parse::<i64>()
986 && number.to_string() == text
987 {
988 return Self::Integer(number);
989 }
990 if let Ok(number) = text.parse::<f64>()
991 && number.is_finite()
992 && number.to_string() == text
993 {
994 return Self::Float(number);
995 }
996 match text {
997 "true" => Self::Bool(true),
998 "false" => Self::Bool(false),
999 _ => Self::Text(text.to_owned()),
1000 }
1001 }
1002}
1003
1004impl Serialize for SpecValue {
1005 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1006 match self {
1007 Self::Integer(number) => serializer.serialize_i64(*number),
1008 Self::Float(number) => serializer.serialize_f64(*number),
1009 Self::Bool(flag) => serializer.serialize_bool(*flag),
1010 Self::Text(text) => serializer.serialize_str(text),
1011 }
1012 }
1013}
1014
1015impl<'de> Deserialize<'de> for SpecValue {
1016 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1017 struct ValueVisitor;
1018
1019 impl Visitor<'_> for ValueVisitor {
1020 type Value = SpecValue;
1021
1022 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1023 formatter.write_str("a number, a bool or a string")
1024 }
1025
1026 fn visit_bool<E: de::Error>(self, flag: bool) -> Result<SpecValue, E> {
1027 Ok(SpecValue::Bool(flag))
1028 }
1029
1030 fn visit_i64<E: de::Error>(self, number: i64) -> Result<SpecValue, E> {
1031 Ok(SpecValue::Integer(number))
1032 }
1033
1034 fn visit_u64<E: de::Error>(self, number: u64) -> Result<SpecValue, E> {
1035 Ok(i64::try_from(number).map_or_else(|_| SpecValue::Text(number.to_string()), SpecValue::Integer))
1036 }
1037
1038 fn visit_f64<E: de::Error>(self, number: f64) -> Result<SpecValue, E> {
1039 Ok(SpecValue::Float(number))
1040 }
1041
1042 fn visit_str<E: de::Error>(self, text: &str) -> Result<SpecValue, E> {
1043 Ok(SpecValue::Text(text.to_owned()))
1044 }
1045 }
1046
1047 deserializer.deserialize_any(ValueVisitor)
1048 }
1049}
1050
1051#[derive(Debug)]
1053pub enum SpecFileError {
1054 Read {
1056 path: PathBuf,
1058 source: io::Error,
1060 },
1061 TablePath {
1063 block: usize,
1065 path: PathBuf,
1067 },
1068 Parse {
1070 source: Box<toml::de::Error>,
1072 },
1073 Json {
1075 source: serde_json::Error,
1077 },
1078 FactorTarget {
1080 block: usize,
1082 },
1083 FactorLevels {
1086 block: usize,
1088 target_name: String,
1090 },
1091 Design {
1093 block: usize,
1095 reason: &'static str,
1097 },
1098 Reducer(ReducerError),
1100 Stop(StopError),
1102 Timeout {
1104 seconds: f64,
1106 },
1107 Search {
1109 reason: &'static str,
1111 },
1112 SearchFactorTarget,
1114 SearchFactorLevels {
1117 target_name: String,
1119 },
1120 Write {
1122 source: toml::ser::Error,
1124 },
1125}
1126
1127impl fmt::Display for SpecFileError {
1128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1129 match self {
1130 Self::Read { path, .. } => write!(f, "cannot read '{}'", path.display()),
1131 Self::TablePath { block, path } => write!(
1132 f,
1133 "block {block}: table file '{}' must be a relative path without '..'",
1134 path.display()
1135 ),
1136 Self::Parse { .. } | Self::Json { .. } => f.write_str("not a valid spec file"),
1137 Self::FactorTarget { block } => {
1138 write!(f, "a factor of block {block} needs exactly one of param and action")
1139 }
1140 Self::FactorLevels { block, target_name } => write!(
1141 f,
1142 "factor '{target_name}' of block {block} needs exactly one of values, range and levels"
1143 ),
1144 Self::Design { block, reason } => write!(f, "block {block}: {reason}"),
1145 Self::Reducer(_) => f.write_str("reducer"),
1146 Self::Stop(_) => f.write_str("stop condition"),
1147 Self::Timeout { seconds } => write!(f, "timeout_s must be a non-negative number of seconds, got {seconds}"),
1148 Self::Search { reason } => write!(f, "search: {reason}"),
1149 Self::SearchFactorTarget => {
1150 f.write_str("a factor of the search space needs exactly one of param and action")
1151 }
1152 Self::SearchFactorLevels { target_name } => write!(
1153 f,
1154 "factor '{target_name}' of the search space needs exactly one of values, range and levels"
1155 ),
1156 Self::Write { .. } => f.write_str("cannot write the spec as TOML"),
1157 }
1158 }
1159}
1160
1161impl std::error::Error for SpecFileError {
1162 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1163 match self {
1164 Self::Read { source, .. } => Some(source),
1165 Self::Parse { source } => Some(source),
1166 Self::Json { source } => Some(source),
1167 Self::Reducer(error) => Some(error),
1168 Self::Stop(error) => Some(error),
1169 Self::Write { source } => Some(source),
1170 Self::TablePath { .. }
1171 | Self::FactorTarget { .. }
1172 | Self::FactorLevels { .. }
1173 | Self::Design { .. }
1174 | Self::Timeout { .. }
1175 | Self::Search { .. }
1176 | Self::SearchFactorTarget
1177 | Self::SearchFactorLevels { .. } => None,
1178 }
1179 }
1180}
1181
1182#[derive(Debug, Clone)]
1184pub struct LoadedSpec {
1185 pub spec: SweepSpec,
1187 pub spec_source: SpecSource,
1189 pub execution: ExecutionTable,
1193}
1194
1195impl LoadedSpec {
1196 pub fn read(path: &Path) -> Result<Self, SpecFileError> {
1202 let (file, text) = SpecFile::load(path)?;
1203 let spec_source = SpecSource::loaded(path, text, &file);
1204 Self::from_file(file, spec_source)
1205 }
1206
1207 pub fn parse(text: &str) -> Result<Self, SpecFileError> {
1214 let file = SpecFile::parse(text)?;
1215 let spec_source = SpecSource {
1216 path: None,
1217 toml: Some(text.to_owned()),
1218 tables: Vec::new(),
1219 };
1220 Self::from_file(file, spec_source)
1221 }
1222
1223 pub fn to_toml(&self) -> Result<String, SpecFileError> {
1231 let mut file = SpecFile::from(&self.spec);
1232 file.execution = self.execution;
1233 file.to_toml().map_err(|source| SpecFileError::Write { source })
1234 }
1235
1236 fn from_file(file: SpecFile, spec_source: SpecSource) -> Result<Self, SpecFileError> {
1237 let execution = file.execution;
1238 Ok(Self {
1239 spec: file.into_spec()?,
1240 spec_source,
1241 execution,
1242 })
1243 }
1244}
1245
1246impl SpecFile {
1247 pub fn load(path: &Path) -> Result<(Self, String), SpecFileError> {
1257 let read = |path: &Path| {
1258 std::fs::read_to_string(path).map_err(|source| SpecFileError::Read {
1259 path: path.to_owned(),
1260 source,
1261 })
1262 };
1263 let text = read(path)?;
1264 let mut file = Self::parse(&text)?;
1265 let directory = path.parent().unwrap_or_else(|| Path::new(""));
1266 for (index, block) in file.blocks.iter_mut().enumerate() {
1267 let Some(table) = block.file.as_ref().filter(|_| block.design == DesignKindFile::Table) else {
1270 continue;
1271 };
1272 if table
1273 .components()
1274 .any(|component| !matches!(component, Component::Normal(_) | Component::CurDir))
1275 {
1276 return Err(SpecFileError::TablePath {
1277 block: index,
1278 path: table.clone(),
1279 });
1280 }
1281 block.file_text = Some(read(&directory.join(table))?);
1282 }
1283 Ok((file, text))
1284 }
1285
1286 pub fn tables(&self) -> Vec<DesignTableFile> {
1288 self.blocks
1289 .iter()
1290 .filter_map(|block| {
1291 Some(DesignTableFile {
1292 path: block.file.clone()?,
1293 fnv1a64: fnv1a64(block.file_text.as_ref()?.as_bytes()),
1294 })
1295 })
1296 .collect()
1297 }
1298
1299 pub fn parse(text: &str) -> Result<Self, SpecFileError> {
1305 toml::from_str(text).map_err(|source| SpecFileError::Parse {
1306 source: Box::new(source),
1307 })
1308 }
1309
1310 pub fn from_json(value: &serde_json::Value) -> Result<Self, SpecFileError> {
1316 Self::deserialize(value).map_err(|source| SpecFileError::Json { source })
1317 }
1318
1319 pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
1325 toml::to_string(self)
1326 }
1327
1328 pub fn into_spec(self) -> Result<SweepSpec, SpecFileError> {
1337 let mut reducers = Vec::new();
1338 for reducer in self.measure.reducers {
1339 for kind in &reducer.kinds {
1340 reducers.push(ReducerSpec {
1341 column: reducer.column.clone(),
1342 kind: kind.parse().map_err(SpecFileError::Reducer)?,
1343 });
1344 }
1345 }
1346 if self.search.is_some() && !self.blocks.is_empty() {
1347 return Err(SpecFileError::Search {
1348 reason: "a search selects its own configs and takes no [[block]] tables",
1349 });
1350 }
1351 let blocks = self
1352 .blocks
1353 .into_iter()
1354 .enumerate()
1355 .map(|(index, block)| block.into_spec(index))
1356 .collect::<Result<_, _>>()?;
1357 let search = self.search.map(SearchTable::into_spec).transpose()?;
1358 let stats_every = self.measure.stats_every;
1359 Ok(SweepSpec {
1360 model: self.model,
1361 fixed: self.set.into_iter().map(|(id, value)| (id, value.to_text())).collect(),
1362 run: self.run.into_settings()?,
1363 measure: MeasureSettings {
1364 stats_every,
1365 series_every: self.measure.series_every.unwrap_or(stats_every),
1366 default_reducers: self.measure.default_reducers,
1367 reducers,
1368 },
1369 seeds: SeedSettings {
1370 root: self.seeds.root.0,
1371 scheme: self.seeds.scheme.into(),
1372 },
1373 actions: self.actions.into_iter().map(ActionSpec::from).collect(),
1374 blocks,
1375 search,
1376 })
1377 }
1378}
1379
1380impl BlockTable {
1381 fn into_spec(self, index: usize) -> Result<BlockSpec, SpecFileError> {
1382 let refuse = |reason| Err(SpecFileError::Design { block: index, reason });
1383 let sampled = matches!(self.design, DesignKindFile::Random | DesignKindFile::Lhs);
1384 if !sampled && self.samples.is_some() {
1385 return refuse("samples applies to a random or lhs design");
1386 }
1387 if !sampled && self.design_seed.is_some() {
1388 return refuse("design_seed applies to a random or lhs design");
1389 }
1390 if self.design != DesignKindFile::Table && (self.file.is_some() || self.table_text.is_some()) {
1391 return refuse("file and table_text apply to a table design");
1392 }
1393 let design = match (self.design, self.samples) {
1394 (DesignKindFile::Factorial, _) => DesignKind::Factorial,
1395 (DesignKindFile::Zip, _) => DesignKind::Zip,
1396 (DesignKindFile::Random, Some(samples)) => DesignKind::Random { samples },
1397 (DesignKindFile::Lhs, Some(samples)) => DesignKind::LatinHypercube { samples },
1398 (DesignKindFile::Random | DesignKindFile::Lhs, None) => {
1399 return refuse("a random or lhs design needs samples");
1400 }
1401 (DesignKindFile::Table, _) => match (self.file, self.file_text, self.table_text) {
1402 (None, _, Some(text)) | (Some(_), Some(text), None) => DesignKind::Table { text },
1403 (Some(_), _, Some(_)) => return refuse("a table design takes one of file and table_text"),
1404 (Some(_), None, None) => {
1405 return refuse("a table file needs a spec file loaded from disk");
1406 }
1407 (None, _, None) => return refuse("a table design needs a file or a table_text"),
1408 },
1409 };
1410 let factors = self
1411 .factors
1412 .into_iter()
1413 .map(|factor| {
1414 factor.into_spec().map_err(|error| match error {
1415 FactorTableError::Target => SpecFileError::FactorTarget { block: index },
1416 FactorTableError::Levels { target_name } => SpecFileError::FactorLevels {
1417 block: index,
1418 target_name,
1419 },
1420 })
1421 })
1422 .collect::<Result<_, _>>()?;
1423 Ok(BlockSpec {
1424 design,
1425 factors,
1426 design_seed: self.design_seed.map(|seed| seed.0),
1427 })
1428 }
1429}
1430
1431impl From<&SweepSpec> for SpecFile {
1432 fn from(spec: &SweepSpec) -> Self {
1435 let mut reducers: Vec<ReducerTable> = Vec::new();
1436 for reducer in &spec.measure.reducers {
1437 match reducers.last_mut() {
1438 Some(last) if last.column == reducer.column => last.kinds.push(reducer.kind.to_string()),
1439 _ => reducers.push(ReducerTable {
1440 column: reducer.column.clone(),
1441 kinds: vec![reducer.kind.to_string()],
1442 }),
1443 }
1444 }
1445 Self {
1446 model: spec.model.clone(),
1447 set: spec
1448 .fixed
1449 .iter()
1450 .map(|(id, value)| (id.clone(), SpecValue::from_text(value)))
1451 .collect(),
1452 run: (&spec.run).into(),
1453 measure: MeasureTable {
1454 stats_every: spec.measure.stats_every,
1455 series_every: Some(spec.measure.series_every),
1456 default_reducers: spec.measure.default_reducers,
1457 reducers,
1458 },
1459 seeds: SeedsTable {
1460 root: TomlSeed(spec.seeds.root),
1461 scheme: spec.seeds.scheme.into(),
1462 },
1463 actions: spec.actions.iter().map(ActionTable::from).collect(),
1464 blocks: spec.blocks.iter().map(BlockTable::from).collect(),
1465 search: spec.search.as_ref().map(SearchTable::from),
1466 execution: ExecutionTable::default(),
1467 }
1468 }
1469}
1470
1471impl From<&BlockSpec> for BlockTable {
1472 fn from(block: &BlockSpec) -> Self {
1475 let factors = block.factors.iter().map(FactorTable::from).collect();
1476 let (samples, table_text) = match &block.design {
1477 DesignKind::Random { samples } | DesignKind::LatinHypercube { samples } => (Some(*samples), None),
1478 DesignKind::Table { text } => (None, Some(text.clone())),
1479 DesignKind::Factorial | DesignKind::Zip => (None, None),
1480 };
1481 Self {
1482 design: (&block.design).into(),
1483 samples,
1484 design_seed: block.design_seed.filter(|_| block.design.is_sampled()).map(TomlSeed),
1485 file: None,
1486 table_text,
1487 factors,
1488 file_text: None,
1489 }
1490 }
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495 use std::num::NonZeroUsize;
1496 use std::path::Path;
1497
1498 use std::time::Duration;
1499
1500 use henad_core::explore::design::DesignKind;
1501 use henad_core::explore::factor::{FactorSpec, LevelSpec};
1502 use henad_core::explore::fingerprint::fnv1a64;
1503 use henad_core::explore::measure::MeasureError;
1504 use henad_core::explore::plan::PlanError;
1505 use henad_core::explore::reducer::{ReducerError, ReducerKind, ReducerSpec};
1506 use henad_core::explore::seed::SeedScheme;
1507 use henad_core::explore::spec::{ActionSpec, BlockSpec, SweepSpec};
1508 use henad_core::explore::stop::StopSpec;
1509
1510 use super::{DesignTableFile, ExecutionTable, SpecFile, SpecFileError, SpecValue};
1511 use crate::exec::Concurrency;
1512 use crate::tests::support::ScratchDir;
1513
1514 const SWEEP: &str = r#"
1515model = "sir"
1516
1517[set]
1518grid_width = 128
1519grid_height = 64
1520neighborhood = "von_neumann"
1521
1522[run]
1523steps = 500
1524warmup = 20
1525replicates = 8
1526
1527[measure]
1528stats_every = 5
1529series_every = 25
1530default_reducers = false
1531reducers = [{ column = "Infected", kinds = ["max", "mean"] }, { column = "Recovered", kinds = ["final"] }]
1532
1533[seeds]
1534root = 42
1535scheme = "independent"
1536
1537[[block]]
1538design = "factorial"
1539factors = [
1540 { param = "infection_rate", range = { min = 0.1, max = 0.5, step = 0.1 } },
1541 { param = "recovery_rate", values = [0.02, 0.05, 1] },
1542]
1543
1544[[block]]
1545design = "zip"
1546factors = [
1547 { param = "wrap", levels = "all" },
1548 { param = "initial_infected", range = { min = 1, max = 2 } },
1549]
1550
1551[execution]
1552concurrent = 4
1553memory = 1000000
1554"#;
1555
1556 fn text(values: &[&str]) -> LevelSpec {
1557 LevelSpec::Values(values.iter().map(|&value| value.to_owned()).collect())
1558 }
1559
1560 fn sweep() -> SweepSpec {
1561 let mut spec = SweepSpec::new("sir");
1562 spec.fixed = vec![
1563 ("grid_height".to_owned(), "64".to_owned()),
1564 ("grid_width".to_owned(), "128".to_owned()),
1565 ("neighborhood".to_owned(), "von_neumann".to_owned()),
1566 ];
1567 spec.run.steps = 500;
1568 spec.run.warmup = 20;
1569 spec.run.replicates = 8;
1570 spec.measure.stats_every = 5;
1571 spec.measure.series_every = 25;
1572 spec.measure.default_reducers = false;
1573 spec.measure.reducers = vec![
1574 ReducerSpec {
1575 column: "Infected".to_owned(),
1576 kind: ReducerKind::Max,
1577 },
1578 ReducerSpec {
1579 column: "Infected".to_owned(),
1580 kind: ReducerKind::Mean,
1581 },
1582 ReducerSpec {
1583 column: "Recovered".to_owned(),
1584 kind: ReducerKind::Final,
1585 },
1586 ];
1587 spec.seeds.root = 42;
1588 spec.seeds.scheme = SeedScheme::Independent;
1589 spec.blocks = vec![
1590 BlockSpec {
1591 design: DesignKind::Factorial,
1592 factors: vec![
1593 FactorSpec::param(
1594 "infection_rate",
1595 LevelSpec::Range {
1596 min: 0.1,
1597 max: 0.5,
1598 step: Some(0.1),
1599 },
1600 ),
1601 FactorSpec::param("recovery_rate", text(&["0.02", "0.05", "1"])),
1602 ],
1603 design_seed: None,
1604 },
1605 BlockSpec {
1606 design: DesignKind::Zip,
1607 factors: vec![
1608 FactorSpec::param("wrap", LevelSpec::All),
1609 FactorSpec::param(
1610 "initial_infected",
1611 LevelSpec::Range {
1612 min: 1.0,
1613 max: 2.0,
1614 step: None,
1615 },
1616 ),
1617 ],
1618 design_seed: None,
1619 },
1620 ];
1621 spec
1622 }
1623
1624 fn parse(text: &str) -> Result<SpecFile, SpecFileError> {
1625 SpecFile::parse(text)
1626 }
1627
1628 #[test]
1629 fn a_spec_file_reads_as_the_sweep_it_writes() {
1630 let file = parse(SWEEP).expect("a valid spec file");
1631 assert_eq!(
1632 file.execution.concurrent,
1633 Concurrency::Fixed(NonZeroUsize::new(4).expect("4 is above 0"))
1634 );
1635 assert_eq!(file.execution.memory, Some(1_000_000));
1636 assert_eq!(file.into_spec().expect("every factor has one form of levels"), sweep());
1637 }
1638
1639 #[test]
1640 fn list_values_reach_the_parser_as_command_line_text() {
1641 let file = parse(
1642 r#"
1643model = "sir"
1644[set]
1645a = 0.1
1646b = 3
1647c = true
1648d = "moore"
1649e = 1e-3
1650"#,
1651 )
1652 .expect("a valid spec file");
1653 let spec = file.into_spec().expect("a valid spec file");
1654 let values: Vec<&str> = spec.fixed.iter().map(|(_, value)| value.as_str()).collect();
1655 assert_eq!(values, ["0.1", "3", "true", "moore", "0.001"]);
1656 }
1657
1658 #[test]
1659 fn a_missing_table_takes_the_defaults() {
1660 let spec = parse("model = \"sir\"\n[measure]\nstats_every = 4\n")
1661 .and_then(SpecFile::into_spec)
1662 .expect("a valid spec file");
1663 let mut expected = SweepSpec::new("sir");
1664 expected.measure.stats_every = 4;
1665 expected.measure.series_every = 4;
1666 assert_eq!(spec, expected, "series_every follows stats_every");
1667 }
1668
1669 #[test]
1670 fn an_unknown_key_is_refused() {
1671 for text in [
1672 "model = \"sir\"\nstep = 3\n",
1673 "model = \"sir\"\n[run]\nstep = 3\n",
1674 "model = \"sir\"\n[seeds]\nseed = 3\n",
1675 "model = \"sir\"\n[measure]\nreducers = [{ column = \"Infected\", kind = \"max\" }]\n",
1676 "model = \"sir\"\n[[block]]\nfactors = [{ param = \"a\", values = [1], step = 2 }]\n",
1677 "model = \"sir\"\n[[block]]\nfactors = [{ param = \"a\", range = { from = 0, to = 1 } }]\n",
1678 "model = \"sir\"\n[execution]\nthreads = 3\n",
1679 ] {
1680 let error = parse(text).expect_err("an unknown key");
1681 assert!(matches!(error, SpecFileError::Parse { .. }), "{text}");
1682 }
1683 }
1684
1685 #[test]
1686 fn a_bad_value_is_refused() {
1687 for text in [
1688 "model = \"sir\"\n[[block]]\ndesign = \"latin\"\n",
1689 "model = \"sir\"\n[[block]]\nfactors = [{ param = \"a\", levels = \"some\" }]\n",
1690 "model = \"sir\"\n[execution]\nconcurrent = 0\n",
1691 "model = \"sir\"\n[seeds]\nroot = -1\n",
1692 "model = \"sir\"\n[set]\na = [1, 2]\n",
1693 "model = \"sir\"\n[run]\nsteps = -5\n",
1694 ] {
1695 assert!(parse(text).is_err(), "{text}");
1696 }
1697 }
1698
1699 #[test]
1700 fn a_factor_needs_exactly_one_form_of_levels() {
1701 for factor in [
1702 "{ param = \"a\" }",
1703 "{ param = \"a\", values = [1], levels = \"all\" }",
1704 "{ param = \"a\", values = [1], range = { min = 0, max = 1 } }",
1705 ] {
1706 let text = format!("model = \"sir\"\n[[block]]\nfactors = [{factor}]\n");
1707 let error = parse(&text)
1708 .and_then(SpecFile::into_spec)
1709 .expect_err("not exactly one form");
1710 assert!(
1711 matches!(error, SpecFileError::FactorLevels { block: 0, .. }),
1712 "{factor} gave {error:?}"
1713 );
1714 }
1715 let error = parse("model = \"sir\"\n[measure]\nreducers = [{ column = \"Infected\", kinds = [\"median\"] }]\n")
1716 .and_then(SpecFile::into_spec)
1717 .expect_err("no such reducer kind");
1718 assert!(matches!(error, SpecFileError::Reducer(_)), "{error:?}");
1719 }
1720
1721 #[test]
1722 fn every_example_spec_parses_and_plans() {
1723 let specs = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("specs");
1724 let models = henad_models::example_models();
1725 let mut planned = Vec::new();
1726 for file in std::fs::read_dir(&specs).expect("the specs directory exists") {
1727 let path = file.expect("the directory lists").path();
1728 if path.extension().is_none_or(|extension| extension != "toml") {
1729 continue;
1730 }
1731 let (file, _) = SpecFile::load(&path).expect("an example spec parses");
1732 let spec = file.into_spec().expect("an example spec is a sweep spec");
1733 if spec.search.is_some() {
1735 continue;
1736 }
1737 let entry = models
1738 .get(&spec.model)
1739 .expect("an example spec names a registered model");
1740 let plan = spec
1741 .plan(&entry.schema())
1742 .unwrap_or_else(|error| panic!("{} does not plan: {error:?}", path.display()));
1743 let name = path.file_name().map(|name| name.to_string_lossy().into_owned());
1744 planned.push((name.unwrap_or_default(), plan.configs().len(), plan.run_count()));
1745 }
1746 for counts in [
1747 ("sir_sweep.toml".to_owned(), 88, 704),
1748 ("sir_table.toml".to_owned(), 4, 16),
1749 ] {
1750 assert!(
1751 planned.contains(&counts),
1752 "the counts its header comment gives: {planned:?}"
1753 );
1754 }
1755 }
1756
1757 #[test]
1758 fn a_seed_past_the_largest_toml_integer_is_a_string() {
1759 let spec = parse("model = \"sir\"\n[seeds]\nroot = \"18446744073709551615\"\n")
1760 .and_then(SpecFile::into_spec)
1761 .expect("a valid seed");
1762 assert_eq!(spec.seeds.root, u64::MAX);
1763 let file = SpecFile::from(&spec);
1764 let text = file.to_toml().expect("a spec file serializes");
1765 assert!(text.contains("root = \"18446744073709551615\""), "{text}");
1766 let small = parse("model = \"sir\"\n[seeds]\nroot = \"7\"\n")
1767 .and_then(SpecFile::into_spec)
1768 .expect("a valid seed");
1769 assert_eq!(small.seeds.root, 7);
1770 }
1771
1772 #[test]
1773 fn a_spec_survives_a_round_trip_through_toml_and_json() {
1774 let spec = sweep();
1775 let text = SpecFile::from(&spec).to_toml().expect("a spec file serializes");
1776 let file = parse(&text).expect("the written file reads back");
1777 assert_eq!(file.clone().into_spec().expect("a valid spec file"), spec, "{text}");
1778 let json = serde_json::to_value(&file).expect("a spec file serializes");
1779 assert_eq!(
1780 json["set"]["grid_width"],
1781 serde_json::json!(128),
1782 "a number stays a number"
1783 );
1784 assert_eq!(json["block"][0]["factors"][1]["values"][0], serde_json::json!(0.02));
1785 let back: SpecFile = serde_json::from_value(json).expect("the JSON reads back");
1786 assert_eq!(back, file);
1787 }
1788
1789 #[test]
1792 fn a_spec_that_plans_reads_back_from_its_file() {
1793 let models = henad_models::example_models();
1794 let schema = models.get("sir").expect("the example set holds SIR").schema();
1795
1796 let mut stop = sweep();
1797 stop.run.stop = Some(StopSpec::parse("Agents (k=3) <= 0.5", 4).expect("a well-formed condition"));
1798 let text = SpecFile::from(&stop).to_toml().expect("a spec file serializes");
1799 let back = parse(&text).and_then(SpecFile::into_spec).expect("the stop reads back");
1800 assert_eq!(back, stop, "{text}");
1801
1802 let mut window = SweepSpec::new("sir");
1803 window.measure.reducers.push(ReducerSpec {
1804 column: "Infected".to_owned(),
1805 kind: ReducerKind::WindowMean { start: 600, end: 200 },
1806 });
1807 assert!(
1808 SpecFile::from(&window).into_spec().is_err(),
1809 "a reversed window does not read back"
1810 );
1811 assert!(
1812 matches!(
1813 window.plan(&schema),
1814 Err(PlanError::Measure(MeasureError::Reducer(
1815 ReducerError::BadWindow { .. }
1816 )))
1817 ),
1818 "the plan refuses a reversed window"
1819 );
1820
1821 let mut timeout = SweepSpec::new("sir");
1822 timeout.run.timeout = Some(Duration::MAX);
1823 assert!(
1824 SpecFile::from(&timeout).into_spec().is_err(),
1825 "the longest timeout does not read back"
1826 );
1827 assert!(
1828 matches!(
1829 timeout.plan(&schema),
1830 Err(PlanError::Measure(MeasureError::TimeoutTooLong { .. }))
1831 ),
1832 "the plan refuses the longest timeout"
1833 );
1834 timeout.run.timeout = Some(Duration::from_secs(1 << 62));
1835 let back = SpecFile::from(&timeout).into_spec().expect("a long timeout reads back");
1836 assert_eq!(back.run.timeout, timeout.run.timeout);
1837 let planned = timeout.plan(&schema);
1838 assert!(planned.is_ok(), "the plan takes a timeout that reads back: {planned:?}");
1839 }
1840
1841 #[test]
1842 fn text_becomes_a_typed_value_only_when_it_writes_back_unchanged() {
1843 assert_eq!(SpecValue::from_text("128"), SpecValue::Integer(128));
1844 assert_eq!(SpecValue::from_text("0.25"), SpecValue::Float(0.25));
1845 assert_eq!(SpecValue::from_text("true"), SpecValue::Bool(true));
1846 for text in ["007", "0.10", "1e-3", "inf", "NaN", "moore", "+5"] {
1847 assert_eq!(SpecValue::from_text(text), SpecValue::Text(text.to_owned()), "{text}");
1848 assert_eq!(SpecValue::from_text(text).to_text(), text);
1849 }
1850 assert_eq!(ExecutionTable::default().concurrent, Concurrency::Auto);
1851 }
1852
1853 const ACTIONS_AND_SAMPLED_DESIGNS: &str = r#"
1854model = "sir"
1855
1856[run]
1857steps = 800
1858timeout_s = 1.5
1859stop = { condition = "Infected <= 0", min_tick = 20 }
1860
1861[measure]
1862reducers = [{ column = "Infected", kinds = ["argmax", "first<=10", "mean@200..600"] }]
1863
1864[[action]]
1865id = "seed_outbreak"
1866tick = 400
1867
1868[[action]]
1869id = "seed_outbreak"
1870name = "second_wave"
1871tick = 500
1872
1873[[block]]
1874design = "lhs"
1875samples = 40
1876design_seed = 7
1877factors = [
1878 { param = "infection_rate", range = { min = 0.05, max = 0.95 } },
1879 { action = "second_wave", range = { min = 200, max = 600 } },
1880]
1881
1882[[block]]
1883design = "random"
1884samples = 3
1885factors = [{ action = "seed_outbreak", values = [100, 200] }]
1886"#;
1887
1888 #[test]
1889 fn a_spec_file_reads_actions_stops_and_sampled_designs() {
1890 let spec = parse(ACTIONS_AND_SAMPLED_DESIGNS)
1891 .and_then(SpecFile::into_spec)
1892 .expect("a valid spec file");
1893 let stop = spec.run.stop.as_ref().expect("the run stops");
1894 assert_eq!((stop.to_string().as_str(), stop.min_tick), ("Infected <= 0", 20));
1895 assert_eq!(spec.run.timeout, Some(Duration::from_millis(1500)));
1896 let kinds: Vec<String> = spec
1897 .measure
1898 .reducers
1899 .iter()
1900 .map(|reducer| reducer.kind.to_string())
1901 .collect();
1902 assert_eq!(kinds, ["argmax", "first<=10", "mean@200..600"]);
1903 assert_eq!(
1904 spec.actions,
1905 [
1906 ActionSpec::new("seed_outbreak", 400),
1907 ActionSpec {
1908 name: "second_wave".to_owned(),
1909 ..ActionSpec::new("seed_outbreak", 500)
1910 },
1911 ]
1912 );
1913 assert_eq!(spec.blocks[0].design, DesignKind::LatinHypercube { samples: 40 });
1914 assert_eq!(spec.blocks[0].design_seed, Some(7));
1915 assert_eq!(
1916 spec.blocks[0].factors[1],
1917 FactorSpec::action(
1918 "second_wave",
1919 LevelSpec::Range {
1920 min: 200.0,
1921 max: 600.0,
1922 step: None
1923 }
1924 )
1925 );
1926 assert_eq!(spec.blocks[1].design, DesignKind::Random { samples: 3 });
1927
1928 let text = SpecFile::from(&spec).to_toml().expect("a spec file serializes");
1929 let back = parse(&text)
1930 .and_then(SpecFile::into_spec)
1931 .expect("the written file reads back");
1932 assert_eq!(back, spec, "{text}");
1933 let models = henad_models::example_models();
1934 let sir = models.get("sir").expect("sir is registered");
1935 let plan = spec.plan(&sir.schema()).expect("the spec plans");
1936 assert_eq!(plan.configs().len(), 43);
1937 }
1938
1939 #[test]
1940 fn a_table_design_is_read_beside_its_spec_file() {
1941 let directory = std::env::temp_dir().join(format!("henad-spec-table-{}", std::process::id()));
1942 std::fs::create_dir_all(&directory).expect("a scratch directory");
1943 let spec_path = directory.join("sweep.toml");
1944 std::fs::write(
1945 &spec_path,
1946 "model = \"sir\"\n[[block]]\ndesign = \"table\"\nfile = \"design.csv\"\n",
1947 )
1948 .expect("the spec writes");
1949 std::fs::write(directory.join("design.csv"), "infection_rate\n0.2\n0.4\n").expect("the table writes");
1950 let (file, _) = SpecFile::load(&spec_path).expect("the spec and its table read");
1951 std::fs::remove_dir_all(&directory).expect("the scratch directory goes");
1952 assert_eq!(
1953 file.tables(),
1954 [DesignTableFile {
1955 path: "design.csv".into(),
1956 fnv1a64: fnv1a64(b"infection_rate\n0.2\n0.4\n"),
1957 }]
1958 );
1959 let spec = file.into_spec().expect("a valid spec file");
1960 assert_eq!(
1961 spec.blocks[0].design,
1962 DesignKind::Table {
1963 text: "infection_rate\n0.2\n0.4\n".to_owned()
1964 }
1965 );
1966 let text = SpecFile::from(&spec).to_toml().expect("a spec file serializes");
1967 let inline = parse(&text)
1968 .and_then(SpecFile::into_spec)
1969 .expect("the table is written in the spec");
1970 assert_eq!(inline, spec, "{text}");
1971 let unread = parse("model = \"sir\"\n[[block]]\ndesign = \"table\"\nfile = \"design.csv\"\n")
1972 .and_then(SpecFile::into_spec)
1973 .expect_err("a table read from nowhere");
1974 assert!(matches!(unread, SpecFileError::Design { block: 0, .. }), "{unread:?}");
1975 }
1976
1977 #[test]
1978 fn a_table_path_cannot_leave_the_spec_directory() {
1979 let scratch = ScratchDir::new("spec-table-path");
1980 let specs = scratch.path().join("specs");
1981 std::fs::create_dir_all(&specs).expect("a scratch directory");
1982 let table = scratch.path().join("design.csv");
1983 std::fs::write(&table, "infection_rate\n0.2\n").expect("the table writes");
1984 let spec_path = specs.join("sweep.toml");
1985 std::fs::write(specs.join("design.csv"), "infection_rate\n0.2\n").expect("the table writes");
1986 std::fs::write(
1987 &spec_path,
1988 "model = \"sir\"\n[[block]]\ndesign = \"table\"\nfile = './design.csv'\n",
1989 )
1990 .expect("the spec writes");
1991 SpecFile::load(&spec_path).expect("a table beside the spec, written with a leading './'");
1992 for path in [
1993 "../design.csv",
1994 table.to_str().expect("a UTF-8 path"),
1995 "tables/../../design.csv",
1996 ] {
1997 let text = format!("model = \"sir\"\n[[block]]\ndesign = \"table\"\nfile = '{path}'\n");
1998 std::fs::write(&spec_path, text).expect("the spec writes");
1999 let error = SpecFile::load(&spec_path).expect_err("a table outside the spec's directory");
2000 let SpecFileError::TablePath {
2001 block: 0,
2002 path: refused,
2003 } = &error
2004 else {
2005 panic!("{path} gave {error:?}");
2006 };
2007 assert_eq!(refused, Path::new(path));
2008 assert!(error.to_string().contains(path), "{error}");
2009 }
2010 }
2011
2012 #[test]
2013 fn a_file_on_another_design_is_refused_without_reading_it() {
2014 let scratch = ScratchDir::new("spec-stray-file");
2015 std::fs::create_dir_all(scratch.path()).expect("a scratch directory");
2016 let spec_path = scratch.path().join("sweep.toml");
2017 for path in ["missing.csv", "../missing.csv"] {
2018 let text = format!("model = \"sir\"\n[[block]]\ndesign = \"factorial\"\nfile = '{path}'\n");
2019 std::fs::write(&spec_path, text).expect("the spec writes");
2020 let (file, _) = SpecFile::load(&spec_path).expect("a factorial design reads no file");
2021 assert_eq!(file.tables(), [], "{path}");
2022 let error = file.into_spec().expect_err("a file on a factorial design");
2023 assert!(
2024 matches!(error, SpecFileError::Design { block: 0, .. }),
2025 "{path} gave {error:?}"
2026 );
2027 }
2028 }
2029
2030 #[test]
2031 fn a_design_seed_is_written_for_a_sampled_design_alone() {
2032 let mut spec = SweepSpec::new("sir");
2033 spec.blocks = vec![BlockSpec {
2034 design: DesignKind::Factorial,
2035 factors: vec![FactorSpec::param("infection_rate", text(&["0.2", "0.4"]))],
2036 design_seed: Some(9),
2037 }];
2038 let text = SpecFile::from(&spec).to_toml().expect("a spec file serializes");
2039 let back = parse(&text)
2040 .and_then(SpecFile::into_spec)
2041 .expect("the written file reads back");
2042 assert_eq!(back.blocks[0].design_seed, None, "{text}");
2043 let models = henad_models::example_models();
2044 let sir = models.get("sir").expect("sir is registered");
2045 let plan_hash = |spec: &SweepSpec| spec.plan(&sir.schema()).expect("the spec plans").plan_hash();
2046 assert_eq!(plan_hash(&back), plan_hash(&spec), "a factorial design draws nothing");
2047 }
2048
2049 #[test]
2050 fn block_keys_must_fit_the_design() {
2051 for block in [
2052 "design = \"lhs\"",
2053 "design = \"random\"",
2054 "design = \"table\"",
2055 "design = \"zip\"\nsamples = 4",
2056 "design = \"factorial\"\ndesign_seed = 4",
2057 "design = \"lhs\"\nsamples = 4\nfile = \"design.csv\"",
2058 "design = \"zip\"\ntable_text = \"a\\n1\\n\"",
2059 "design = \"table\"\nfile = \"design.csv\"\ntable_text = \"a\\n1\\n\"",
2060 ] {
2061 let text = format!("model = \"sir\"\n[[block]]\n{block}\n");
2062 let error = parse(&text)
2063 .and_then(SpecFile::into_spec)
2064 .expect_err("keys that do not fit");
2065 assert!(
2066 matches!(error, SpecFileError::Design { block: 0, .. }),
2067 "{block} gave {error:?}"
2068 );
2069 }
2070 for factor in ["{ values = [1] }", "{ param = \"a\", action = \"b\", values = [1] }"] {
2071 let text = format!("model = \"sir\"\n[[block]]\nfactors = [{factor}]\n");
2072 let error = parse(&text)
2073 .and_then(SpecFile::into_spec)
2074 .expect_err("no single target");
2075 assert!(
2076 matches!(error, SpecFileError::FactorTarget { block: 0 }),
2077 "{factor} gave {error:?}"
2078 );
2079 }
2080 for run in ["stop = { condition = \"Infected\" }", "timeout_s = -1.0"] {
2081 let text = format!("model = \"sir\"\n[run]\n{run}\n");
2082 let error = parse(&text)
2083 .and_then(SpecFile::into_spec)
2084 .expect_err("an unreadable run table");
2085 assert!(
2086 matches!(error, SpecFileError::Stop(_) | SpecFileError::Timeout { .. }),
2087 "{run} gave {error:?}"
2088 );
2089 }
2090 }
2091
2092 const SEARCH: &str = r#"
2093model = "sir"
2094
2095[[action]]
2096id = "seed_outbreak"
2097tick = 50
2098
2099[search]
2100algorithm = "hill_climb"
2101max_evaluations = 40
2102batch_size = 8
2103objective = { column = "Infected:max", goal = "maximize" }
2104space = [
2105 { param = "infection_rate", range = { min = 0.1, max = 0.9 } },
2106 { action = "seed_outbreak", values = [10, 20, 30] },
2107]
2108
2109[search.hill_climb]
2110patience = 3
2111"#;
2112
2113 #[test]
2114 fn a_search_table_reads_as_the_search_it_writes() {
2115 use henad_core::explore::search::hill_climb::HillClimbSettings;
2116 use henad_core::explore::search::{Aggregate, Goal, SearchAlgorithm};
2117
2118 let spec = parse(SEARCH).and_then(SpecFile::into_spec).expect("a valid search");
2119 let search = spec.search.as_ref().expect("the spec is a search");
2120 assert_eq!(
2121 search.algorithm,
2122 SearchAlgorithm::HillClimb(HillClimbSettings {
2123 patience: 3,
2124 ..HillClimbSettings::default()
2125 }),
2126 "a left-out setting takes its default"
2127 );
2128 assert_eq!((search.max_evaluations, search.batch_size), (40, 8));
2129 let objective = search.objective.as_ref().expect("a hill climb has an objective");
2130 assert_eq!(
2131 (objective.goal, objective.aggregate),
2132 (Goal::Maximize, Aggregate::Median)
2133 );
2134 assert_eq!(
2135 search.space[1],
2136 FactorSpec::action("seed_outbreak", text(&["10", "20", "30"]))
2137 );
2138 let written = SpecFile::from(&spec).to_toml().expect("a spec file serializes");
2139 let back = parse(&written)
2140 .and_then(SpecFile::into_spec)
2141 .expect("the written file reads back");
2142 assert_eq!(back, spec, "{written}");
2143 }
2144
2145 #[test]
2146 fn search_keys_must_fit_the_algorithm() {
2147 let with = |extra: &str| format!("{SEARCH}{extra}");
2148 for (text, reason) in [
2149 (
2150 with("[search.genetic]\npopulation = 4\n"),
2151 "a genetic table on a hill climb",
2152 ),
2153 (
2154 SEARCH
2155 .replace("hill_climb\"\n", "pse\"\n")
2156 .replace("[search.hill_climb]\npatience = 3\n", ""),
2157 "a pse search without its axes",
2158 ),
2159 (with("[[block]]\nfactors = []\n"), "blocks beside a search"),
2160 ] {
2161 let error = parse(&text).and_then(SpecFile::into_spec).expect_err(reason);
2162 assert!(matches!(error, SpecFileError::Search { .. }), "{reason}: {error:?}");
2163 }
2164 for text in [
2165 with("[search.pse]\nx_axis = { column = \"a\", min = 0, max = 1, cells = 2, width = 3 }\n"),
2166 SEARCH.replace("patience = 3", "patient = 3"),
2167 SEARCH.replace("batch_size = 8", "batch = 8"),
2168 SEARCH.replace("goal = \"maximize\"", "goal = \"max\""),
2169 ] {
2170 let error = parse(&text).expect_err("an unknown key or value");
2171 assert!(matches!(error, SpecFileError::Parse { .. }), "{text}");
2172 }
2173 let error = parse(&SEARCH.replace("{ action = \"seed_outbreak\", values", "{ values"))
2174 .and_then(SpecFile::into_spec)
2175 .expect_err("a factor with no target");
2176 assert!(matches!(error, SpecFileError::SearchFactorTarget), "{error:?}");
2177 }
2178
2179 #[test]
2180 fn a_pse_axis_takes_both_bounds_or_neither() {
2181 use henad_core::explore::search::SearchAlgorithm;
2182 use henad_core::explore::search::pse::PatternAxis;
2183
2184 let pse = |x_axis: &str| {
2185 SEARCH
2186 .replace("hill_climb\"\n", "pse\"\n")
2187 .replace("objective = { column = \"Infected:max\", goal = \"maximize\" }\n", "")
2188 .replace(
2189 "[search.hill_climb]\npatience = 3\n",
2190 &format!(
2191 "[search.pse]\nx_axis = {x_axis}\ny_axis = {{ column = \"Infected:argmax\", min = 0, max = 50, cells = 10 }}\n"
2192 ),
2193 )
2194 };
2195 let spec = parse(&pse("{ column = \"Infected:max\", cells = 16 }"))
2196 .and_then(SpecFile::into_spec)
2197 .expect("an automatic range reads");
2198 let search = spec.search.as_ref().expect("the spec is a search");
2199 let SearchAlgorithm::PatternSpaceExploration(settings) = &search.algorithm else {
2200 panic!("a pse search reads as one: {:?}", search.algorithm);
2201 };
2202 assert_eq!(settings.x_axis, PatternAxis::automatic("Infected:max", 16));
2203 assert_eq!(settings.y_axis, PatternAxis::bounded("Infected:argmax", 0.0, 50.0, 10));
2204 let written = SpecFile::from(&spec).to_toml().expect("a spec file serializes");
2205 let back = parse(&written)
2206 .and_then(SpecFile::into_spec)
2207 .expect("the written file reads back");
2208 assert_eq!(back, spec, "{written}");
2209
2210 for (x_axis, bound) in [
2211 ("{ column = \"Infected:max\", min = 0, cells = 16 }", "min"),
2212 ("{ column = \"Infected:max\", max = 900, cells = 16 }", "max"),
2213 ] {
2214 let error = parse(&pse(x_axis))
2215 .and_then(SpecFile::into_spec)
2216 .expect_err("one bound alone");
2217 assert!(matches!(error, SpecFileError::Search { .. }), "{bound}: {error:?}");
2218 assert_eq!(
2219 error.to_string(),
2220 "search: x_axis needs both min and max, or neither for an automatic range",
2221 "{bound}"
2222 );
2223 }
2224 }
2225}