Skip to main content

henad_explore/
spec_file.rs

1//! Spec files, the TOML form of a sweep spec.
2//!
3//! Every table rejects an unknown key. A parameter value is kept as the text that `--set` and `--vary` accept, so a
4//! spec file and a command line pass [`parse_value`] the same text.
5//!
6//! [`parse_value`]: henad_core::explore::value::parse_value
7
8use 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/// A sweep spec as written in a TOML file.
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct SpecFile {
37    /// Id of the model to run.
38    pub model: String,
39    /// Values every config shares, by parameter id.
40    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
41    pub set: BTreeMap<String, SpecValue>,
42    /// Length and end of each run, the `[run]` table.
43    #[serde(default)]
44    pub run: RunTable,
45    /// Sampling cadence and reducers, the `[measure]` table.
46    #[serde(default)]
47    pub measure: MeasureTable,
48    /// Root seed and seed scheme, the `[seeds]` table.
49    #[serde(default)]
50    pub seeds: SeedsTable,
51    /// Actions every run fires, each an `[[action]]` table.
52    #[serde(default, rename = "action", skip_serializing_if = "Vec::is_empty")]
53    pub actions: Vec<ActionTable>,
54    /// Blocks in order, each a `[[block]]` table.
55    #[serde(default, rename = "block", skip_serializing_if = "Vec::is_empty")]
56    pub blocks: Vec<BlockTable>,
57    /// Search that picks the configs instead of blocks.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub search: Option<SearchTable>,
60    /// Settings that spread the runs over the machine and never change a result.
61    #[serde(default, skip_serializing_if = "ExecutionTable::is_default")]
62    pub execution: ExecutionTable,
63}
64
65/// Length and end of each run, and the number of runs per config.
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields, default)]
68pub struct RunTable {
69    /// Number of ticks stepped after the warm-up.
70    pub steps: u64,
71    /// Number of ticks stepped before the first sample.
72    pub warmup: u64,
73    /// Number of runs of each config, each with its own seed.
74    pub replicates: u64,
75    /// Condition that can end a run before its last tick.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub stop: Option<StopTable>,
78    /// Seconds of wall-clock time after which a run is abandoned. On a GPU track, a run's clock counts its share of the
79    /// time the sweep spends on the tracks, so the run can take longer than this in real time.
80    #[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    /// Returns the run settings the table describes.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`SpecFileError::Stop`] for a stop condition that cannot be read, and [`SpecFileError::Timeout`] for
111    /// a timeout that is negative, not a number or too long for a [`Duration`].
112    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/// Condition that ends a run at the first sample where it holds, as in `Infected <= 0`.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(deny_unknown_fields)]
139pub struct StopTable {
140    /// Text of the condition, a stat label, a comparator and a threshold.
141    pub condition: String,
142    /// First tick at which the condition can end a run.
143    #[serde(default)]
144    pub min_tick: u64,
145}
146
147/// A model action every run fires.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(deny_unknown_fields)]
150pub struct ActionTable {
151    /// Id of the action the model declares.
152    pub id: String,
153    /// Name that factors and output columns use for the action, the id when left out.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub name: Option<String>,
156    /// Tick the action fires at, where no block varies it.
157    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/// Sampling cadence and reducers.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(deny_unknown_fields, default)]
183pub struct MeasureTable {
184    /// Ticks between two samples, counted from the end of the warm-up.
185    pub stats_every: u64,
186    /// Ticks between two rows of the series, `stats_every` when left out.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub series_every: Option<u64>,
189    /// Whether every column other than a histogram bucket gets the final, min, max and mean reducers.
190    pub default_reducers: bool,
191    /// Reducers added after the defaults.
192    #[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/// Reducers over one stat column, one per kind.
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct ReducerTable {
212    /// Stat column the reducers read.
213    pub column: String,
214    /// Kinds of reducer, as in `max` or `first<=10`.
215    pub kinds: Vec<String>,
216}
217
218/// Root seed and seed scheme.
219#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(deny_unknown_fields, default)]
221pub struct SeedsTable {
222    /// Seed from which every run's seed is derived.
223    pub root: TomlSeed,
224    /// Rule that derives each run's seed from the root.
225    pub scheme: SeedSchemeFile,
226}
227
228/// Seed written as an integer or, past the largest TOML integer, as a decimal string.
229#[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/// Seed scheme, as [`SeedScheme::as_str`] names it.
276#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "snake_case")]
278pub enum SeedSchemeFile {
279    /// Replicate `r` of every config shares one seed.
280    #[default]
281    Common,
282    /// Every run gets its own seed.
283    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/// Factors combined under one design.
305#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
306#[serde(deny_unknown_fields)]
307pub struct BlockTable {
308    /// Design that combines the factors, `factorial` when left out.
309    #[serde(default)]
310    pub design: DesignKindFile,
311    /// Number of configs that a random or Latin hypercube design draws.
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub samples: Option<usize>,
314    /// Seed of a random or Latin hypercube design's draws, derived from the root seed when left out.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub design_seed: Option<TomlSeed>,
317    /// Path of a table design's CSV file, relative to the spec file.
318    #[serde(default, skip_serializing_if = "Option::is_none")]
319    pub file: Option<PathBuf>,
320    /// Text of a table design's CSV, written in the spec instead of `file`.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub table_text: Option<String>,
323    /// Factors of the block. A table design takes its factors from its table, and lists no factors here.
324    #[serde(default)]
325    pub factors: Vec<FactorTable>,
326    /// Text of the table that `file` refers to, read by [`SpecFile::load`].
327    #[serde(skip)]
328    pub file_text: Option<String>,
329}
330
331/// Design table a spec file reads, as its path and the hash of its text.
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct DesignTableFile {
334    /// Path as written in the spec file.
335    pub path: PathBuf,
336    /// FNV-1a hash of the table's text.
337    pub fnv1a64: u64,
338}
339
340/// Design of a block, as [`DesignKind::as_str`] names it.
341#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
342#[serde(rename_all = "snake_case")]
343pub enum DesignKindFile {
344    /// Every combination of levels.
345    #[default]
346    Factorial,
347    /// Levels paired by position.
348    Zip,
349    /// `samples` configs, each factor's value drawn independently.
350    Random,
351    /// `samples` configs in a Latin hypercube.
352    Lhs,
353    /// One config per row of a CSV table, from `file` or `table_text`.
354    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/// A factor over one parameter or one action's tick, with exactly one of `values`, `range` and `levels`.
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
371#[serde(deny_unknown_fields)]
372pub struct FactorTable {
373    /// Id of the parameter the factor varies.
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub param: Option<String>,
376    /// Name of the action whose tick the factor varies.
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub action: Option<String>,
379    /// Levels listed one by one.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub values: Option<Vec<SpecValue>>,
382    /// Levels spread over a range.
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub range: Option<RangeTable>,
385    /// Levels named by a keyword.
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub levels: Option<LevelsKeyword>,
388}
389
390/// Every value from `min` to `max` inclusive, `step` apart, as [`LevelSpec::Range`] interprets them.
391#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
392#[serde(deny_unknown_fields)]
393pub struct RangeTable {
394    /// Lowest value.
395    pub min: f64,
396    /// Highest value.
397    pub max: f64,
398    /// Distance between two values.
399    ///
400    /// Without a step, a sampled design or a search draws from the whole range. Any other design steps a whole-number
401    /// factor by 1, and rejects an `F32` parameter.
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub step: Option<f64>,
404}
405
406/// Levels named by a keyword.
407#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
408#[serde(rename_all = "snake_case")]
409pub enum LevelsKeyword {
410    /// Every value of a `Bool` or `Choice` parameter.
411    All,
412}
413
414/// A factor table without exactly one target or one form of levels.
415enum FactorTableError {
416    /// None or both of `param` and `action`.
417    Target,
418    /// None or several of `values`, `range` and `levels`, on the parameter or action `target_name`.
419    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    /// Writes each value as [`SpecValue::from_text`] converts it.
448    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/// A search, the `[search]` table.
472///
473/// The table of the chosen algorithm, `[search.hill_climb]`, `[search.genetic]` or `[search.pse]`, holds its
474/// settings. A random search has no settings. For any other algorithm, a left-out table takes the algorithm's
475/// defaults, except for `[search.pse]`, whose axes are needed.
476#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
477#[serde(deny_unknown_fields)]
478pub struct SearchTable {
479    /// Search method.
480    pub algorithm: SearchAlgorithmFile,
481    /// Number of evaluations that the search can request, re-evaluations included.
482    pub max_evaluations: u64,
483    /// Maximum number of candidates that one batch evaluates.
484    pub batch_size: usize,
485    /// Output that a random search, hill climb or genetic algorithm scores.
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub objective: Option<ObjectiveTable>,
488    /// Factors the search varies, in the form a block's factors take.
489    pub space: Vec<FactorTable>,
490    /// Settings of a hill climb, the `[search.hill_climb]` table.
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub hill_climb: Option<HillClimbTable>,
493    /// Settings of a genetic algorithm, the `[search.genetic]` table.
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub genetic: Option<GeneticTable>,
496    /// Settings of a Pattern Space Exploration, the `[search.pse]` table.
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub pse: Option<PatternSpaceTable>,
499}
500
501/// Search method, as [`SearchAlgorithm::as_str`] names it.
502#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
503#[serde(rename_all = "snake_case")]
504pub enum SearchAlgorithmFile {
505    /// Random search.
506    Random,
507    /// Hill climbing.
508    HillClimb,
509    /// Genetic algorithm.
510    Genetic,
511    /// Pattern Space Exploration.
512    Pse,
513}
514
515/// Output that a search uses to score candidates, as `{ column, goal, aggregate }`.
516#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
517#[serde(deny_unknown_fields)]
518pub struct ObjectiveTable {
519    /// Reducer column, as in `Infected:max`.
520    pub column: String,
521    /// Direction of the objective.
522    pub goal: GoalFile,
523    /// Rule that folds a candidate's replicates into one value, the median when left out.
524    #[serde(default)]
525    pub aggregate: AggregateFile,
526}
527
528/// Direction of an objective, as [`Goal::as_str`] names it.
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
530#[serde(rename_all = "snake_case")]
531pub enum GoalFile {
532    /// Lower values score better.
533    Minimize,
534    /// Higher values score better.
535    Maximize,
536}
537
538/// Rule that folds replicates into one value, as [`Aggregate::as_str`] names it.
539#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
540#[serde(rename_all = "snake_case")]
541pub enum AggregateFile {
542    /// Mean of the values.
543    Mean,
544    /// Middle value, or the mean of the middle two for an even count.
545    #[default]
546    Median,
547}
548
549/// Settings of a hill climb, each defaulting to [`HillClimbSettings::default`].
550#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
551#[serde(deny_unknown_fields, default)]
552pub struct HillClimbTable {
553    /// Largest step of a gene toward a neighbor, as a fraction of its range.
554    pub mutation_scale: f64,
555    /// Number of batches without a move after which the climb starts over.
556    pub patience: u64,
557    /// Whether each batch also re-evaluates the incumbent.
558    pub reevaluate: bool,
559}
560
561impl Default for HillClimbTable {
562    fn default() -> Self {
563        (&HillClimbSettings::default()).into()
564    }
565}
566
567/// Settings of a genetic algorithm, each defaulting to [`GeneticSettings::default`].
568#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
569#[serde(deny_unknown_fields, default)]
570pub struct GeneticTable {
571    /// Number of members in each generation.
572    pub population: usize,
573    /// Number of best members of a generation carried unchanged into the next.
574    pub elite_count: usize,
575    /// Number of members drawn for each tournament, the best of whom becomes a parent.
576    pub tournament_size: usize,
577    /// Probability that a child has a second parent.
578    pub crossover_rate: f64,
579    /// Probability that each gene of a child changes.
580    pub mutation_rate: f64,
581    /// Largest step of a changed gene, as a fraction of its range.
582    pub mutation_scale: f64,
583    /// Share of the population re-evaluated each generation, rounded up.
584    pub reevaluate_fraction: f64,
585}
586
587impl Default for GeneticTable {
588    fn default() -> Self {
589        (&GeneticSettings::default()).into()
590    }
591}
592
593/// Settings of a Pattern Space Exploration.
594///
595/// The axes are needed, and the rest default as [`PatternSpaceSettings::new`] sets them.
596#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
597#[serde(deny_unknown_fields)]
598pub struct PatternSpaceTable {
599    /// Axis of the first output.
600    pub x_axis: PatternAxisTable,
601    /// Axis of the second output.
602    pub y_axis: PatternAxisTable,
603    /// Number of candidates drawn at random before any candidate is bred from the archive.
604    #[serde(default = "default_initial_samples")]
605    pub initial_samples: u64,
606    /// Largest step of a mutated gene, as a fraction of its range.
607    #[serde(default = "default_pattern_mutation_scale")]
608    pub mutation_scale: f64,
609    /// Rule that folds a candidate's replicates into one value per axis, the median when left out.
610    #[serde(default)]
611    pub aggregate: AggregateFile,
612}
613
614/// One axis of a Pattern Space Exploration's grid, as `{ column, min, max, cells }`, or as `{ column, cells }` for a
615/// range taken from the initial samples.
616#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
617#[serde(deny_unknown_fields)]
618pub struct PatternAxisTable {
619    /// Reducer column, as in `Infected:max`.
620    pub column: String,
621    /// Lower bound, left out with `max` for an automatic range.
622    #[serde(default, skip_serializing_if = "Option::is_none")]
623    pub min: Option<f64>,
624    /// Upper bound, left out with `min` for an automatic range.
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub max: Option<f64>,
627    /// Number of cells from `min` to `max`.
628    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    /// Returns the axis the table describes.
705    ///
706    /// # Errors
707    ///
708    /// Returns [`SpecFileError::Search`] with the reason `lone_bound` for a table with one bound and not the other.
709    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    /// Returns the settings the table describes.
736    ///
737    /// # Errors
738    ///
739    /// Returns [`SpecFileError::Search`] for an axis with one bound and not the other.
740    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    /// Returns the search spec the table describes.
793    ///
794    /// # Errors
795    ///
796    /// Returns [`SpecFileError`] for a factor without exactly one target or one form of levels, a settings table for
797    /// an algorithm other than the chosen one, or a Pattern Space Exploration with no `[search.pse]` table. The
798    /// settings themselves are checked when the search is planned.
799    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    /// Writes the settings of the chosen algorithm in full, in its own table.
845    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/// Settings that spread the runs over the machine.
880#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
881#[serde(deny_unknown_fields, default)]
882pub struct ExecutionTable {
883    /// `"auto"`, or a number of CPU lanes or GPU tracks.
884    #[serde(with = "concurrent_field")]
885    pub concurrent: Concurrency,
886    /// Host memory budget in bytes for all live runs together.
887    #[serde(skip_serializing_if = "Option::is_none")]
888    pub memory: Option<u64>,
889    /// Device memory budget in bytes for all live GPU runs together.
890    #[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
900/// Serde form of [`ExecutionTable::concurrent`], `"auto"` or a positive integer.
901mod 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/// A parameter value as written in a spec file.
955#[derive(Debug, Clone, PartialEq)]
956pub enum SpecValue {
957    /// Integer, as in `3`.
958    Integer(i64),
959    /// Float, as in `0.1`.
960    Float(f64),
961    /// `true` or `false`.
962    Bool(bool),
963    /// String, as in a choice's option name.
964    Text(String),
965}
966
967impl SpecValue {
968    /// Returns the value as text in the form that `--set` accepts.
969    ///
970    /// A float is written in its shortest round-trip form, as in `0.1`.
971    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    /// Returns `text` as the value [`Self::to_text`] writes back as `text`.
981    ///
982    /// Text that parses as an integer, a finite float or a bool, and is written back unchanged, becomes that kind of
983    /// value. Anything else stays text.
984    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/// A spec file that cannot be read or turned into a sweep spec.
1052#[derive(Debug)]
1053pub enum SpecFileError {
1054    /// Reading the file at `path` failed.
1055    Read {
1056        /// Path of the spec file, or of a design table that it refers to, joined to the spec file's directory.
1057        path: PathBuf,
1058        /// Error from reading the file, of kind `InvalidData` for text that is not UTF-8.
1059        source: io::Error,
1060    },
1061    /// Table path `path` of block `block`, absolute or holding a component other than a name, such as `..`.
1062    TablePath {
1063        /// Index of the block, counting from 0.
1064        block: usize,
1065        /// Table path as written in the block's `file` key.
1066        path: PathBuf,
1067    },
1068    /// Text that is not a spec file.
1069    Parse {
1070        /// Error from the TOML parser.
1071        source: Box<toml::de::Error>,
1072    },
1073    /// JSON that is not a spec file.
1074    Json {
1075        /// Error from the JSON deserializer.
1076        source: serde_json::Error,
1077    },
1078    /// A factor of block `block` with none or both of `param` and `action`.
1079    FactorTarget {
1080        /// Index of the block, counting from 0.
1081        block: usize,
1082    },
1083    /// Factor of block `block` on the parameter or action `target_name`, with none or several of `values`, `range`
1084    /// and `levels`.
1085    FactorLevels {
1086        /// Index of the block, counting from 0.
1087        block: usize,
1088        /// Parameter id or action name the factor targets.
1089        target_name: String,
1090    },
1091    /// Block `block`, whose keys do not fit its design.
1092    Design {
1093        /// Index of the block, counting from 0.
1094        block: usize,
1095        /// Message that identifies the key at fault, such as `samples applies to a random or lhs design`.
1096        reason: &'static str,
1097    },
1098    /// A reducer kind that cannot be read.
1099    Reducer(ReducerError),
1100    /// A stop condition that cannot be read.
1101    Stop(StopError),
1102    /// A timeout of `seconds` that is negative, not a number or too long for a [`Duration`].
1103    Timeout {
1104        /// Value of `timeout_s` in seconds, as written in the spec.
1105        seconds: f64,
1106    },
1107    /// A `[search]` table whose keys do not fit its algorithm or the rest of the spec.
1108    Search {
1109        /// Message that identifies the key at fault, such as `[search.pse] applies to a pse search`.
1110        reason: &'static str,
1111    },
1112    /// A factor of the search space with none or both of `param` and `action`.
1113    SearchFactorTarget,
1114    /// A factor of the search space on the parameter or action `target_name`, with none or several of `values`,
1115    /// `range` and `levels`.
1116    SearchFactorLevels {
1117        /// Parameter id or action name the factor targets.
1118        target_name: String,
1119    },
1120    /// A spec that cannot be written as TOML.
1121    Write {
1122        /// Error from the TOML serializer.
1123        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/// A spec file read whole: the spec, the text it was read from, and its execution settings.
1183#[derive(Debug, Clone)]
1184pub struct LoadedSpec {
1185    /// Sweep spec the file describes.
1186    pub spec: SweepSpec,
1187    /// Path, text and design tables of the file, as a manifest records them.
1188    pub spec_source: SpecSource,
1189    /// The file's `[execution]` table, for [`SweepOptions::apply_execution`] to apply.
1190    ///
1191    /// [`SweepOptions::apply_execution`]: crate::sweep::SweepOptions::apply_execution
1192    pub execution: ExecutionTable,
1193}
1194
1195impl LoadedSpec {
1196    /// Reads the file at `path`, reading a table design's file relative to it.
1197    ///
1198    /// # Errors
1199    ///
1200    /// Returns the errors of [`SpecFile::load`] and [`SpecFile::into_spec`].
1201    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    /// Reads spec text that refers to no table file.
1208    ///
1209    /// # Errors
1210    ///
1211    /// Returns the errors of [`SpecFile::parse`] and [`SpecFile::into_spec`]. A block that refers to a table `file` is
1212    /// rejected, since no file is read. A table design given inline as `table_text` is read.
1213    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    /// Writes the spec and its execution table as spec-file text, as the app's Save spec does.
1224    ///
1225    /// A table design's rows are written inline, as a manifest records them.
1226    ///
1227    /// # Errors
1228    ///
1229    /// Returns [`SpecFileError::Write`] when the spec cannot be written as TOML.
1230    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    /// Reads the spec file at `path`, and the design tables that its blocks refer to, and returns it with its text as
1248    /// written.
1249    ///
1250    /// A table's path is relative to the directory of `path` and cannot leave it. Only a table design's `file` is read.
1251    ///
1252    /// # Errors
1253    ///
1254    /// Returns [`SpecFileError::Read`] when the file or a table cannot be read, [`SpecFileError::TablePath`] for a
1255    /// table path that is absolute or holds a component other than a name, and the errors of [`Self::parse`].
1256    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            // Any other design rejects a file in `into_spec`. If the file were read here, a missing file would be
1268            // reported instead of that error.
1269            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    /// Returns the design tables [`Self::load`] read, in block order.
1287    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    /// Reads a spec file from its text.
1300    ///
1301    /// # Errors
1302    ///
1303    /// Returns [`SpecFileError::Parse`] for text that is not TOML, or a key or value that no table accepts.
1304    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    /// Reads a spec file from `value`, the JSON form a manifest records.
1311    ///
1312    /// # Errors
1313    ///
1314    /// Returns [`SpecFileError::Json`] for a value that no table accepts.
1315    pub fn from_json(value: &serde_json::Value) -> Result<Self, SpecFileError> {
1316        Self::deserialize(value).map_err(|source| SpecFileError::Json { source })
1317    }
1318
1319    /// Returns the file as TOML text.
1320    ///
1321    /// # Errors
1322    ///
1323    /// Returns the serializer's error. Every field of a spec file has a TOML form, so no error is expected.
1324    pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
1325        toml::to_string(self)
1326    }
1327
1328    /// Returns the sweep spec the file describes, leaving its execution settings behind.
1329    ///
1330    /// # Errors
1331    ///
1332    /// Returns [`SpecFileError`] for a factor without exactly one target or one form of levels, block keys that do
1333    /// not fit the design, a table that was never read, a reducer kind, stop condition or timeout that cannot be
1334    /// read, or a `[search]` table beside blocks or with settings of a different algorithm. Everything else is checked
1335    /// when the spec is planned.
1336    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    /// Writes each value as [`SpecValue::from_text`] converts it, and each consecutive group of reducers over one
1433    /// column as one entry.
1434    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    /// Writes a table design's text in [`BlockTable::table_text`], and a design seed for a random or Latin hypercube
1473    /// design alone.
1474    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            // A search is planned in its own test, every_example_search_spec_parses.
1734            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    /// Checks that every spec the plan accepts reads back from the file its manifest records, and that the plan
1790    /// rejects the two that cannot.
1791    #[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}