Skip to main content

henad_core/explore/
plan.rs

1//! Plans that check a sweep spec against a model and list every config and run.
2
3use std::fmt;
4use std::ops::Range;
5use std::str::FromStr;
6
7use crate::action::{ActionDescriptor, Schedule, Scheduled};
8use crate::explore::design::{Block, DesignError, DesignKind, MAX_CONFIGS, generate_within};
9use crate::explore::design_csv::{DesignTableError, read_table};
10use crate::explore::factor::{FactorError, FactorSlot, FactorTarget};
11use crate::explore::fingerprint::{plan_hash, results_fingerprint, run_key, schema_hash};
12use crate::explore::measure::{MeasureError, MeasurePlan};
13use crate::explore::outcome::PlannedRun;
14use crate::explore::replay::Replay;
15use crate::explore::seed::design_seed;
16use crate::explore::spec::{ActionSpec, BlockSpec, MeasureSettings, RunSettings, SeedSettings, SweepSpec};
17use crate::explore::stop::StopError;
18use crate::explore::value::{ValueError, resolve_params};
19use crate::params::{ParamDescriptor, ParamValue};
20use crate::view::StatDescriptor;
21
22/// Maximum number of runs in one plan, every config times its replicates, equal to [`MAX_CONFIGS`].
23///
24/// Note that the limit bounds the number of runs alone. A sweep lists every run it has left before the first run
25/// starts, and at the limit those lists take about 2 GB, more when the configs schedule actions.
26pub const MAX_RUNS: u64 = 1 << 24;
27
28/// Declarations of a model, as a plan checks a spec against them.
29#[derive(Debug, Clone, Copy)]
30pub struct ModelSchema<'a> {
31    /// Id of the model.
32    pub id: &'a str,
33    /// Parameters the model declares.
34    pub params: &'a [ParamDescriptor],
35    /// Stats the model declares.
36    pub stats: &'a [StatDescriptor],
37    /// Actions the model declares.
38    pub actions: &'a [ActionDescriptor],
39}
40
41/// One set of parameter values and action ticks a sweep runs.
42#[derive(Debug, Clone, PartialEq)]
43pub struct Config {
44    /// Index of the block the config comes from.
45    pub block: usize,
46    /// One value per parameter, in descriptor order.
47    pub params: Vec<ParamValue>,
48    /// Tick of each of the spec's actions, in spec order.
49    pub action_ticks: Vec<u64>,
50}
51
52/// Design and configs of one block of a plan.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct PlannedBlock {
55    /// Design that combined the block's factors.
56    pub design: DesignKind,
57    /// Ids of the block's configs.
58    pub configs: Range<u64>,
59    /// Seed the block's configs were drawn from, `None` for a design that draws nothing.
60    pub design_seed: Option<u64>,
61}
62
63/// Part of a plan that runs, though likely not as intended.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum PlanWarning {
66    /// Action `name`, due past the run's `last_tick` in `config_count` configs, at `latest_tick` at the latest.
67    ActionAfterEnd {
68        /// Name of the action.
69        name: String,
70        /// Latest tick the action is due at.
71        latest_tick: u64,
72        /// Tick a run ends on unless it stops sooner, the warm-up included.
73        last_tick: u64,
74        /// Number of configs whose tick for the action is past `last_tick`.
75        config_count: u64,
76    },
77}
78
79impl fmt::Display for PlanWarning {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Self::ActionAfterEnd {
83                name,
84                latest_tick,
85                last_tick,
86                config_count,
87            } => {
88                let configs = if *config_count == 1 { "config" } else { "configs" };
89                write!(
90                    f,
91                    "action '{name}' is due after the last tick {last_tick} in {config_count} {configs} (latest tick \
92                     {latest_tick}) and will not run there"
93                )
94            }
95        }
96    }
97}
98
99/// Share of a plan's runs, the runs whose id leaves remainder `index` when divided by `count`.
100///
101/// Written `INDEX/COUNT`, as in `0/4`.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub struct Shard {
104    index: u64,
105    count: u64,
106}
107
108impl Shard {
109    /// Every run of a plan.
110    pub const WHOLE: Self = Self { index: 0, count: 1 };
111
112    /// Returns shard `index` of `count`.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`ShardError`] for a `count` of 0, or an `index` not below `count`.
117    pub fn new(index: u64, count: u64) -> Result<Self, ShardError> {
118        if count == 0 {
119            return Err(ShardError::ZeroCount);
120        }
121        if index >= count {
122            return Err(ShardError::IndexPastCount { index, count });
123        }
124        Ok(Self { index, count })
125    }
126
127    /// Remainder the shard's run ids leave when divided by [`Self::count`].
128    pub fn index(self) -> u64 {
129        self.index
130    }
131
132    /// Number of shards the plan is split into.
133    pub fn count(self) -> u64 {
134        self.count
135    }
136
137    /// Returns whether run `run_id` falls in the shard.
138    pub fn contains(self, run_id: u64) -> bool {
139        run_id % self.count == self.index
140    }
141
142    /// Returns the number of runs in the shard for a plan of `plan_runs` runs.
143    pub fn run_count(self, plan_runs: u64) -> u64 {
144        if self.index < plan_runs {
145            (plan_runs - 1 - self.index) / self.count + 1
146        } else {
147            0
148        }
149    }
150}
151
152impl Default for Shard {
153    fn default() -> Self {
154        Self::WHOLE
155    }
156}
157
158impl fmt::Display for Shard {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        write!(f, "{}/{}", self.index, self.count)
161    }
162}
163
164impl FromStr for Shard {
165    type Err = ShardError;
166
167    /// Reads `INDEX/COUNT`.
168    fn from_str(raw: &str) -> Result<Self, Self::Err> {
169        let (index, count) = raw
170            .split_once('/')
171            .and_then(|(index, count)| Some((index.trim().parse().ok()?, count.trim().parse().ok()?)))
172            .ok_or_else(|| ShardError::BadText { raw: raw.to_owned() })?;
173        Self::new(index, count)
174    }
175}
176
177/// A shard that cannot be made.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum ShardError {
180    /// Text not of the form `INDEX/COUNT`.
181    BadText {
182        /// Text as written.
183        raw: String,
184    },
185    /// A shard count of 0.
186    ZeroCount,
187    /// An index not below the count.
188    IndexPastCount {
189        /// Index of the shard, as given.
190        index: u64,
191        /// Number of shards, as given.
192        count: u64,
193    },
194}
195
196impl fmt::Display for ShardError {
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        match self {
199            Self::BadText { raw } => write!(f, "invalid shard '{raw}', expected INDEX/COUNT"),
200            Self::ZeroCount => write!(f, "shard count must be at least 1"),
201            Self::IndexPastCount { index, count } => {
202                write!(f, "shard index {index} must be less than shard count {count}")
203            }
204        }
205    }
206}
207
208impl std::error::Error for ShardError {}
209
210/// A sweep spec checked against a model, with every config listed.
211///
212/// Runs are numbered config by config, so run `run_id` is replicate `run_id % replicates` of config
213/// `run_id / replicates`.
214#[derive(Debug, Clone, PartialEq)]
215pub struct Plan {
216    model: String,
217    configs: Vec<Config>,
218    blocks: Vec<PlannedBlock>,
219    run_settings: RunSettings,
220    measure: MeasureSettings,
221    seeds: SeedSettings,
222    actions: Vec<ActionSpec>,
223    /// Index of each action in the model's declarations, in spec order.
224    action_indices: Vec<usize>,
225    warnings: Vec<PlanWarning>,
226    run_count: u64,
227    schema_hash: u64,
228    results_fingerprint: u64,
229    plan_hash: u64,
230}
231
232impl Plan {
233    /// Id of the model the plan runs.
234    pub fn model(&self) -> &str {
235        &self.model
236    }
237
238    /// Every config, indexed by config id.
239    pub fn configs(&self) -> &[Config] {
240        &self.configs
241    }
242
243    /// Returns config `config_id`, or `None` past the last config.
244    pub fn config(&self, config_id: u64) -> Option<&Config> {
245        self.configs.get(usize::try_from(config_id).ok()?)
246    }
247
248    /// Every block, in spec order.
249    pub fn blocks(&self) -> &[PlannedBlock] {
250        &self.blocks
251    }
252
253    /// Length and end of each run, and the number of runs per config.
254    pub fn run_settings(&self) -> &RunSettings {
255        &self.run_settings
256    }
257
258    /// Ticks each run samples, and the values it keeps from them.
259    pub fn measure_settings(&self) -> &MeasureSettings {
260        &self.measure
261    }
262
263    /// Root seed and the scheme that derives each run's seed from it.
264    pub fn seed_settings(&self) -> SeedSettings {
265        self.seeds
266    }
267
268    /// Actions every run fires, in spec order, the order of [`Config::action_ticks`].
269    pub fn actions(&self) -> &[ActionSpec] {
270        &self.actions
271    }
272
273    /// Warnings about parts of the plan that run, though likely not as intended.
274    pub fn warnings(&self) -> &[PlanWarning] {
275        &self.warnings
276    }
277
278    /// Number of runs per config.
279    pub fn replicates(&self) -> u64 {
280        self.run_settings.replicates
281    }
282
283    /// Number of runs, every config times its replicates.
284    pub fn run_count(&self) -> u64 {
285        self.run_count
286    }
287
288    /// Returns run `run_id`, or `None` past the last run.
289    pub fn run(&self, run_id: u64) -> Option<PlannedRun> {
290        (run_id < self.run_count).then(|| self.planned_run(run_id))
291    }
292
293    /// Returns every run in order.
294    pub fn runs(&self) -> impl Iterator<Item = PlannedRun> + '_ {
295        (0..self.run_count).map(|run_id| self.planned_run(run_id))
296    }
297
298    /// Returns the runs of `shard` in order.
299    pub fn runs_in_shard(&self, shard: Shard) -> impl Iterator<Item = PlannedRun> + '_ {
300        (0..shard.run_count(self.run_count)).map(move |position| self.planned_run(shard.index + position * shard.count))
301    }
302
303    fn planned_run(&self, run_id: u64) -> PlannedRun {
304        let config_id = run_id / self.run_settings.replicates;
305        let rep = run_id % self.run_settings.replicates;
306        PlannedRun {
307            run_id,
308            config_id,
309            rep,
310            seed: self.seeds.scheme.seed(self.seeds.root, config_id, rep),
311        }
312    }
313
314    /// Returns the actions of `config` in the order they fire, by tick and then in spec order.
315    ///
316    /// # Panics
317    ///
318    /// Panics when `config` does not hold one tick per action of the plan.
319    pub fn schedule(&self, config: &Config) -> Schedule {
320        assert_eq!(
321            config.action_ticks.len(),
322            self.actions.len(),
323            "a config holds one tick per action"
324        );
325        let mut entries: Vec<Scheduled> = self
326            .actions
327            .iter()
328            .zip(&self.action_indices)
329            .zip(&config.action_ticks)
330            .map(|((action, &index), &tick)| Scheduled {
331                index,
332                id: action.id.clone(),
333                tick,
334            })
335            .collect();
336        entries.sort_by_key(|entry| entry.tick);
337        Schedule::from_entries(entries)
338    }
339
340    /// Returns the [`Replay`] of run `run_id`, or `None` past the last run.
341    pub fn replay(&self, run_id: u64) -> Option<Replay> {
342        let run = self.run(run_id)?;
343        let config = self.config(run.config_id)?;
344        Some(Replay {
345            model: self.model.clone(),
346            params: config.params.clone(),
347            seed: run.seed,
348            schedule: self.schedule(config),
349            ticks: self.run_settings.warmup + self.run_settings.steps,
350            label: format!("Sweep run {run_id}: config {}, replicate {}", run.config_id, run.rep),
351        })
352    }
353
354    /// Returns the key that identifies the results of `run`, from [`run_key`].
355    ///
356    /// # Panics
357    ///
358    /// Panics when the config of `run` is not in this plan.
359    pub fn run_key(&self, run: &PlannedRun) -> u64 {
360        let config = self
361            .config(run.config_id)
362            .expect("a planned run's config is in its plan");
363        run_key(self.results_fingerprint, &config.params, &config.action_ticks, run.seed)
364    }
365
366    /// Hash of the model's declarations, from [`schema_hash`].
367    pub fn schema_hash(&self) -> u64 {
368        self.schema_hash
369    }
370
371    /// Hash of every setting that shapes the results of one run, from [`results_fingerprint`].
372    pub fn results_fingerprint(&self) -> u64 {
373        self.results_fingerprint
374    }
375
376    /// Hash of every setting that fixes the configs and their seeds, from [`plan_hash`].
377    ///
378    /// Note that the replicate count and the timeout are left out.
379    pub fn plan_hash(&self) -> u64 {
380        self.plan_hash
381    }
382}
383
384impl SweepSpec {
385    /// Checks the spec against `schema`, and lists its configs.
386    ///
387    /// # Errors
388    ///
389    /// Returns [`PlanError`] for a spec that refers to another model, no replicates, settings [`MeasurePlan::check`]
390    /// rejects, a reducer or stop condition over no stat label, an action the model does not declare or a name two
391    /// actions share, a value or factor that the model's parameters or the spec's actions reject, a parameter both
392    /// fixed and varied, a target varied twice in one block, a block whose design cannot combine its factors, or more
393    /// than [`MAX_RUNS`] runs.
394    pub fn plan(&self, schema: &ModelSchema<'_>) -> Result<Plan, PlanError> {
395        if self.model != schema.id {
396            return Err(PlanError::WrongModel {
397                spec_model: self.model.clone(),
398                schema_model: schema.id.to_owned(),
399            });
400        }
401        if self.run.replicates == 0 {
402            return Err(PlanError::NoReplicates);
403        }
404        MeasurePlan::check(&self.run, &self.measure).map_err(PlanError::Measure)?;
405        for reducer in &self.measure.reducers {
406            reducer
407                .check_label(schema.stats)
408                .map_err(|error| PlanError::Measure(MeasureError::Reducer(error)))?;
409        }
410        if let Some(stop) = &self.run.stop {
411            stop.check_label(schema.stats).map_err(PlanError::Stop)?;
412        }
413        let action_indices = self.resolve_actions(schema)?;
414        let fixed = resolve_params(schema.params, &self.fixed).map_err(|error| match error {
415            ValueError::UnknownParam { id, known } => PlanError::UnknownFixed { id, known },
416            error => PlanError::Fixed(error),
417        })?;
418        let fixed_ticks: Vec<u64> = self.actions.iter().map(|action| action.tick).collect();
419
420        let lone_block = BlockSpec::default();
421        let block_specs = if self.blocks.is_empty() {
422            std::slice::from_ref(&lone_block)
423        } else {
424            self.blocks.as_slice()
425        };
426        let mut configs = Vec::new();
427        let mut blocks = Vec::with_capacity(block_specs.len());
428        for (index, block_spec) in block_specs.iter().enumerate() {
429            let block = self.resolve_block(index, block_spec, schema)?;
430            let base = Config {
431                block: index,
432                params: fixed.clone(),
433                action_ticks: fixed_ticks.clone(),
434            };
435            let start = configs.len() as u64;
436            let limit = MAX_CONFIGS - configs.len();
437            configs.extend(
438                generate_within(&block, &base, limit).map_err(|source| PlanError::Design { block: index, source })?,
439            );
440            blocks.push(PlannedBlock {
441                design: block.design.clone(),
442                configs: start..configs.len() as u64,
443                design_seed: block.design.is_sampled().then_some(block.design_seed),
444            });
445        }
446
447        let run_count = (configs.len() as u64)
448            .checked_mul(self.run.replicates)
449            .filter(|&count| count <= MAX_RUNS)
450            .ok_or(PlanError::TooManyRuns)?;
451        let schema_hash = schema_hash(schema);
452        let results_fingerprint = results_fingerprint(schema_hash, &self.run, &self.measure, &self.actions);
453        let plan_hash = plan_hash(results_fingerprint, &self.seeds, &self.actions, &blocks, &configs);
454        let warnings = late_actions(&self.actions, &configs, self.run.warmup + self.run.steps);
455        Ok(Plan {
456            model: self.model.clone(),
457            configs,
458            blocks,
459            run_settings: self.run.clone(),
460            measure: self.measure.clone(),
461            seeds: self.seeds,
462            actions: self.actions.clone(),
463            action_indices,
464            warnings,
465            run_count,
466            schema_hash,
467            results_fingerprint,
468            plan_hash,
469        })
470    }
471
472    /// Returns the index of each action in the model's declarations.
473    fn resolve_actions(&self, schema: &ModelSchema<'_>) -> Result<Vec<usize>, PlanError> {
474        let mut indices = Vec::with_capacity(self.actions.len());
475        for (position, action) in self.actions.iter().enumerate() {
476            if self.actions[..position]
477                .iter()
478                .any(|earlier| earlier.name == action.name)
479            {
480                return Err(PlanError::DuplicateActionName {
481                    name: action.name.clone(),
482                });
483            }
484            let index = schema
485                .actions
486                .iter()
487                .position(|declared| declared.id == action.id)
488                .ok_or_else(|| PlanError::UnknownAction {
489                    id: action.id.clone(),
490                    known: schema.actions.iter().map(|declared| declared.id).collect(),
491                })?;
492            indices.push(index);
493        }
494        Ok(indices)
495    }
496
497    /// Resolves the factors of block `index` against the model's parameters and the spec's actions.
498    fn resolve_block(
499        &self,
500        index: usize,
501        block_spec: &BlockSpec,
502        schema: &ModelSchema<'_>,
503    ) -> Result<Block, PlanError> {
504        let factors = if let DesignKind::Table { text } = &block_spec.design {
505            if !block_spec.factors.is_empty() {
506                return Err(PlanError::TableWithFactors { block: index });
507            }
508            read_table(text, schema.params, &self.actions)
509                .map_err(|source| PlanError::Table { block: index, source })?
510        } else {
511            let mut factors = Vec::with_capacity(block_spec.factors.len());
512            for (position, factor) in block_spec.factors.iter().enumerate() {
513                if block_spec.factors[..position]
514                    .iter()
515                    .any(|earlier| earlier.target == factor.target)
516                {
517                    return Err(PlanError::VariedTwice {
518                        target: factor.target.clone(),
519                        block: index,
520                    });
521                }
522                factors.push(
523                    factor
524                        .resolve(schema.params, &self.actions, &block_spec.design)
525                        .map_err(|source| PlanError::Factor { block: index, source })?,
526                );
527            }
528            factors
529        };
530        for factor in &factors {
531            if let FactorSlot::Param(param) = factor.slot {
532                let id = schema.params[param].id;
533                if self.fixed.iter().any(|(fixed_id, _)| fixed_id == id) {
534                    return Err(PlanError::FixedAndVaried {
535                        id: id.to_owned(),
536                        block: index,
537                    });
538                }
539            }
540        }
541        Ok(Block {
542            design: block_spec.design.clone(),
543            factors,
544            design_seed: block_spec
545                .design_seed
546                .unwrap_or_else(|| design_seed(self.seeds.root, index)),
547        })
548    }
549}
550
551/// Returns a warning for each action due after `last_tick` in some config.
552fn late_actions(actions: &[ActionSpec], configs: &[Config], last_tick: u64) -> Vec<PlanWarning> {
553    let mut warnings = Vec::new();
554    for (position, action) in actions.iter().enumerate() {
555        let late = configs
556            .iter()
557            .map(|config| config.action_ticks[position])
558            .filter(|&tick| tick > last_tick);
559        let (count, latest) = late.fold((0, 0), |(count, latest), tick| (count + 1, latest.max(tick)));
560        if count > 0 {
561            warnings.push(PlanWarning::ActionAfterEnd {
562                name: action.name.clone(),
563                latest_tick: latest,
564                last_tick,
565                config_count: count,
566            });
567        }
568    }
569    warnings
570}
571
572/// A sweep spec that cannot be planned against a model.
573#[derive(Debug, Clone, PartialEq)]
574pub enum PlanError {
575    /// A spec for model `spec_model`, checked against model `schema_model`.
576    WrongModel {
577        /// Model id in the spec.
578        spec_model: String,
579        /// Id of the model the spec was checked against.
580        schema_model: String,
581    },
582    /// A replicate count of 0.
583    NoReplicates,
584    /// Run and sampling settings under which a run cannot be measured.
585    Measure(MeasureError),
586    /// A stop condition over no stat label.
587    Stop(StopError),
588    /// An action id the model does not declare.
589    UnknownAction {
590        /// Action id as given.
591        id: String,
592        /// Action ids the model declares.
593        known: Vec<&'static str>,
594    },
595    /// A name that two actions of the spec share.
596    DuplicateActionName {
597        /// Name the actions share.
598        name: String,
599    },
600    /// A fixed value that the model rejects.
601    Fixed(ValueError),
602    /// A fixed value for an id that no parameter has.
603    UnknownFixed {
604        /// Parameter id as given.
605        id: String,
606        /// Parameter ids the model declares.
607        known: Vec<&'static str>,
608    },
609    /// Parameter `id`, varied in block `block` and fixed as well.
610    FixedAndVaried {
611        /// Id of the parameter.
612        id: String,
613        /// Index of the block, counting from 0.
614        block: usize,
615    },
616    /// A target varied twice in block `block`.
617    VariedTwice {
618        /// Parameter or action tick varied twice.
619        target: FactorTarget,
620        /// Index of the block, counting from 0.
621        block: usize,
622    },
623    /// A rejected factor of block `block`.
624    Factor {
625        /// Index of the block, counting from 0.
626        block: usize,
627        /// Reason the factor is rejected.
628        source: FactorError,
629    },
630    /// A table design in block `block` that lists its own factors.
631    TableWithFactors {
632        /// Index of the block, counting from 0.
633        block: usize,
634    },
635    /// A rejected design table of block `block`.
636    Table {
637        /// Index of the block, counting from 0.
638        block: usize,
639        /// Reason the table is rejected.
640        source: DesignTableError,
641    },
642    /// Block `block`, whose design cannot combine its factors.
643    Design {
644        /// Index of the block, counting from 0.
645        block: usize,
646        /// Reason the design cannot combine the factors.
647        source: DesignError,
648    },
649    /// More runs than [`MAX_RUNS`].
650    TooManyRuns,
651}
652
653impl fmt::Display for PlanError {
654    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
655        match self {
656            Self::WrongModel {
657                spec_model,
658                schema_model,
659            } => {
660                write!(f, "spec is for model '{spec_model}', expected '{schema_model}'")
661            }
662            Self::NoReplicates => write!(f, "replicates must be at least 1"),
663            Self::Measure(_) => write!(f, "measurement settings"),
664            Self::Stop(_) => write!(f, "stop condition"),
665            Self::UnknownAction { id, known } if known.is_empty() => {
666                write!(f, "unknown action '{id}' (model declares no actions)")
667            }
668            Self::UnknownAction { id, known } => {
669                write!(f, "unknown action '{id}', expected one of {}", known.join(", "))
670            }
671            Self::DuplicateActionName { name } => {
672                write!(f, "two actions are named '{name}', and a name must be unique")
673            }
674            Self::Fixed(_) => write!(f, "fixed values"),
675            Self::UnknownFixed { id, known } => {
676                write!(f, "unknown parameter '{id}', expected one of {}", known.join(", "))
677            }
678            Self::FixedAndVaried { id, block } => {
679                write!(f, "parameter '{id}' is both fixed and varied in block {block}")
680            }
681            Self::VariedTwice { target, block } => write!(f, "block {block} varies {target} twice"),
682            Self::TableWithFactors { block } => {
683                write!(f, "block {block} reads its configs from a table, and takes no factors")
684            }
685            Self::Factor { block, .. } | Self::Table { block, .. } | Self::Design { block, .. } => {
686                write!(f, "block {block}")
687            }
688            Self::TooManyRuns => write!(f, "plan has more than {MAX_RUNS} runs"),
689        }
690    }
691}
692
693impl std::error::Error for PlanError {
694    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
695        match self {
696            Self::Measure(error) => Some(error),
697            Self::Stop(error) => Some(error),
698            Self::Fixed(error) => Some(error),
699            Self::Factor { source, .. } => Some(source),
700            Self::Table { source, .. } => Some(source),
701            Self::Design { source, .. } => Some(source),
702            Self::WrongModel { .. }
703            | Self::NoReplicates
704            | Self::UnknownAction { .. }
705            | Self::DuplicateActionName { .. }
706            | Self::UnknownFixed { .. }
707            | Self::FixedAndVaried { .. }
708            | Self::VariedTwice { .. }
709            | Self::TableWithFactors { .. }
710            | Self::TooManyRuns => None,
711        }
712    }
713}
714
715#[cfg(test)]
716mod tests {
717    use std::collections::BTreeSet;
718
719    use super::{MAX_RUNS, ModelSchema, PlanError, PlanWarning, Shard, ShardError};
720    use crate::action::{ActionDescriptor, Scheduled};
721    use crate::explore::design::{DesignError, DesignKind};
722    use crate::explore::design_csv::DesignTableError;
723    use crate::explore::factor::{FactorError, FactorSpec, FactorTarget, LevelSpec};
724    use crate::explore::measure::MeasureError;
725    use crate::explore::reducer::{ReducerError, ReducerKind, ReducerSpec};
726    use crate::explore::seed::{SeedScheme, design_seed, run_seed};
727    use crate::explore::spec::{ActionSpec, BlockSpec, SweepSpec};
728    use crate::explore::stop::{StopError, StopSpec};
729    use crate::helpers::{choice_param, f32_param, u32_param};
730    use crate::params::{ParamDescriptor, ParamValue};
731    use crate::view::StatDescriptor;
732
733    const COLOR: [u8; 4] = [0, 0, 0, 255];
734    const STATS: &[StatDescriptor] = &[
735        StatDescriptor::new("Infected", COLOR),
736        StatDescriptor::new("Recovered", COLOR),
737    ];
738    const ACTIONS: &[ActionDescriptor] = &[
739        ActionDescriptor::new("seed_outbreak", "Seed outbreak"),
740        ActionDescriptor::new("vaccinate", "Vaccinate"),
741    ];
742    const NEIGHBORHOODS: &[&str] = &["moore", "von_neumann"];
743
744    fn params() -> Vec<ParamDescriptor> {
745        vec![
746            f32_param("infection_rate", "Infection rate", 0.2, 0.0, 1.0, Some(0.01)),
747            f32_param("recovery_rate", "Recovery rate", 0.05, 0.0, 1.0, None),
748            u32_param("grid_width", "Grid width", 64, 1, 4096),
749            choice_param("neighborhood", "Neighborhood", NEIGHBORHOODS, 0),
750        ]
751    }
752
753    fn values(raw: &[&str]) -> LevelSpec {
754        LevelSpec::Values(raw.iter().map(|&text| text.to_owned()).collect())
755    }
756
757    fn block(design: DesignKind, factors: Vec<FactorSpec>) -> BlockSpec {
758        BlockSpec {
759            design,
760            factors,
761            design_seed: None,
762        }
763    }
764
765    fn plan(spec: &SweepSpec) -> Result<super::Plan, PlanError> {
766        let params = params();
767        let schema = ModelSchema {
768            id: "sir",
769            params: &params,
770            stats: STATS,
771            actions: ACTIONS,
772        };
773        spec.plan(&schema)
774    }
775
776    /// Returns a spec with two actions, the second named `second_wave`.
777    fn spec_with_actions() -> SweepSpec {
778        let mut spec = SweepSpec::new("sir");
779        spec.run.steps = 100;
780        spec.run.replicates = 2;
781        spec.actions = vec![
782            ActionSpec::new("seed_outbreak", 50),
783            ActionSpec {
784                name: "second_wave".to_owned(),
785                ..ActionSpec::new("seed_outbreak", 50)
786            },
787        ];
788        spec
789    }
790
791    #[test]
792    fn blocks_concatenate_and_number_configs_in_order() {
793        let mut spec = SweepSpec::new("sir");
794        spec.blocks = vec![
795            block(
796                DesignKind::Factorial,
797                vec![FactorSpec::param("grid_width", values(&["16", "32"]))],
798            ),
799            block(
800                DesignKind::Zip,
801                vec![
802                    FactorSpec::param("infection_rate", values(&["0.1", "0.2", "0.3"])),
803                    FactorSpec::param("neighborhood", values(&["von_neumann", "moore", "1"])),
804                ],
805            ),
806        ];
807        let plan = plan(&spec).expect("a valid spec");
808        let blocks: Vec<usize> = plan.configs().iter().map(|config| config.block).collect();
809        assert_eq!(blocks, [0, 0, 1, 1, 1]);
810        assert_eq!(plan.blocks()[0].configs, 0..2);
811        assert_eq!(plan.blocks()[1].configs, 2..5);
812        assert_eq!(plan.blocks()[1].design, DesignKind::Zip);
813        let second = plan.config(3).expect("config 3 exists");
814        assert_eq!(
815            second.params,
816            [
817                ParamValue::F32(0.2),
818                ParamValue::F32(0.05),
819                ParamValue::U32(64),
820                ParamValue::Choice(0)
821            ],
822            "a block leaves the parameters it does not vary at their fixed values"
823        );
824    }
825
826    #[test]
827    fn blocks_past_the_limit_together_are_refused() {
828        // Block 1 alone holds exactly `MAX_CONFIGS`, 4096 widths by 4096 ticks.
829        let mut spec = SweepSpec::new("sir");
830        spec.actions = vec![ActionSpec::new("seed_outbreak", 0)];
831        spec.blocks = vec![
832            block(
833                DesignKind::Factorial,
834                vec![FactorSpec::param("neighborhood", LevelSpec::All)],
835            ),
836            block(
837                DesignKind::Factorial,
838                vec![
839                    FactorSpec::param(
840                        "grid_width",
841                        LevelSpec::Range {
842                            min: 1.0,
843                            max: 4096.0,
844                            step: None,
845                        },
846                    ),
847                    FactorSpec::action(
848                        "seed_outbreak",
849                        LevelSpec::Range {
850                            min: 0.0,
851                            max: 4095.0,
852                            step: None,
853                        },
854                    ),
855                ],
856            ),
857        ];
858        assert!(matches!(
859            plan(&spec),
860            Err(PlanError::Design {
861                block: 1,
862                source: DesignError::TooManyConfigs
863            })
864        ));
865    }
866
867    #[test]
868    fn a_plan_past_the_run_limit_is_refused() {
869        let mut spec = SweepSpec::new("sir");
870        spec.run.replicates = MAX_RUNS;
871        assert_eq!(plan(&spec).expect("a plan at the limit").run_count(), MAX_RUNS);
872        spec.run.replicates = MAX_RUNS + 1;
873        assert!(matches!(plan(&spec), Err(PlanError::TooManyRuns)));
874    }
875
876    #[test]
877    fn runs_are_numbered_config_by_config() {
878        let mut spec = SweepSpec::new("sir");
879        spec.run.replicates = 3;
880        spec.seeds.root = 42;
881        spec.blocks = vec![block(
882            DesignKind::Factorial,
883            vec![FactorSpec::param("grid_width", values(&["16", "32"]))],
884        )];
885        let common = plan(&spec).expect("a valid spec");
886        assert_eq!(common.run_count(), 6);
887        let runs: Vec<(u64, u64, u64)> = common.runs().map(|run| (run.run_id, run.config_id, run.rep)).collect();
888        assert_eq!(runs, [(0, 0, 0), (1, 0, 1), (2, 0, 2), (3, 1, 0), (4, 1, 1), (5, 1, 2)]);
889        let fifth = common.run(4).expect("run 4 exists");
890        let second = common.run(1).expect("run 1 exists");
891        assert_eq!(fifth.seed, run_seed(42, 1), "common random numbers by default");
892        assert_eq!(fifth.seed, second.seed);
893        assert_ne!(common.run_key(&fifth), common.run_key(&second), "the configs differ");
894        assert_eq!(common.run(6), None);
895
896        spec.seeds.scheme = SeedScheme::Independent;
897        let independent = plan(&spec).expect("a valid spec");
898        assert_ne!(
899            independent.run(4).map(|run| run.seed),
900            independent.run(1).map(|run| run.seed)
901        );
902    }
903
904    #[test]
905    fn a_spec_with_no_blocks_runs_its_fixed_values() {
906        let mut spec = SweepSpec::new("sir");
907        spec.fixed = vec![("grid_width".to_owned(), "128".to_owned())];
908        let plan = plan(&spec).expect("a valid spec");
909        assert_eq!(plan.configs().len(), 1);
910        assert_eq!(plan.configs()[0].params[2], ParamValue::U32(128));
911        assert_eq!(plan.blocks().len(), 1);
912    }
913
914    #[test]
915    fn a_parameter_varied_twice_in_a_block_is_refused() {
916        let mut spec = SweepSpec::new("sir");
917        spec.blocks = vec![block(
918            DesignKind::Factorial,
919            vec![
920                FactorSpec::param("grid_width", values(&["16"])),
921                FactorSpec::param("infection_rate", values(&["0.1"])),
922                FactorSpec::param("grid_width", values(&["32"])),
923            ],
924        )];
925        assert_eq!(
926            plan(&spec),
927            Err(PlanError::VariedTwice {
928                target: FactorTarget::Param("grid_width".to_owned()),
929                block: 0
930            })
931        );
932        assert_eq!(
933            plan(&spec).map_err(|error| error.to_string()),
934            Err("block 0 varies parameter 'grid_width' twice".to_owned())
935        );
936        spec.blocks.insert(0, block(DesignKind::Factorial, Vec::new()));
937        assert!(
938            matches!(plan(&spec), Err(PlanError::VariedTwice { block: 1, .. })),
939            "the error names the block"
940        );
941
942        let mut apart = SweepSpec::new("sir");
943        apart.blocks = vec![
944            block(
945                DesignKind::Factorial,
946                vec![FactorSpec::param("grid_width", values(&["16"]))],
947            ),
948            block(
949                DesignKind::Factorial,
950                vec![FactorSpec::param("grid_width", values(&["32"]))],
951            ),
952        ];
953        assert!(plan(&apart).is_ok(), "two blocks can vary one parameter");
954    }
955
956    #[test]
957    fn a_parameter_both_fixed_and_varied_is_refused() {
958        let mut spec = SweepSpec::new("sir");
959        spec.fixed = vec![("grid_width".to_owned(), "128".to_owned())];
960        spec.blocks = vec![block(
961            DesignKind::Factorial,
962            vec![FactorSpec::param("grid_width", values(&["16", "32"]))],
963        )];
964        assert_eq!(
965            plan(&spec),
966            Err(PlanError::FixedAndVaried {
967                id: "grid_width".to_owned(),
968                block: 0
969            })
970        );
971    }
972
973    #[test]
974    fn a_spec_the_model_refuses_is_refused() {
975        let mut other_model = SweepSpec::new("boids");
976        assert!(matches!(plan(&other_model), Err(PlanError::WrongModel { .. })));
977        other_model.model = "sir".to_owned();
978
979        let mut spec = other_model.clone();
980        spec.run.replicates = 0;
981        assert_eq!(plan(&spec), Err(PlanError::NoReplicates));
982
983        let mut spec = other_model.clone();
984        spec.measure.stats_every = 0;
985        assert_eq!(plan(&spec), Err(PlanError::Measure(MeasureError::ZeroStatsEvery)));
986
987        let mut spec = other_model.clone();
988        spec.measure.reducers = vec![ReducerSpec {
989            column: "Susceptible".to_owned(),
990            kind: ReducerKind::Max,
991        }];
992        assert!(matches!(
993            plan(&spec),
994            Err(PlanError::Measure(MeasureError::Reducer(
995                ReducerError::UnknownColumn { .. }
996            )))
997        ));
998
999        let mut spec = other_model.clone();
1000        spec.fixed = vec![("grid_width".to_owned(), "0".to_owned())];
1001        assert!(matches!(plan(&spec), Err(PlanError::Fixed(_))));
1002
1003        let mut spec = other_model.clone();
1004        spec.fixed = vec![("nope".to_owned(), "1".to_owned())];
1005        let error = plan(&spec).expect_err("the model has no parameter 'nope'");
1006        assert_eq!(
1007            error.to_string(),
1008            "unknown parameter 'nope', expected one of infection_rate, recovery_rate, grid_width, neighborhood"
1009        );
1010
1011        let mut spec = other_model.clone();
1012        spec.blocks = vec![block(
1013            DesignKind::Factorial,
1014            vec![FactorSpec::param(
1015                "infection_rate",
1016                LevelSpec::Range {
1017                    min: 0.1,
1018                    max: 0.5,
1019                    step: None,
1020                },
1021            )],
1022        )];
1023        assert!(
1024            matches!(
1025                plan(&spec),
1026                Err(PlanError::Factor {
1027                    block: 0,
1028                    source: FactorError::MissingStep { .. }
1029                })
1030            ),
1031            "a factorial over an F32 range needs a step"
1032        );
1033
1034        let mut spec = other_model;
1035        spec.blocks = vec![block(
1036            DesignKind::Zip,
1037            vec![
1038                FactorSpec::param("grid_width", values(&["16", "32"])),
1039                FactorSpec::param("infection_rate", values(&["0.1"])),
1040            ],
1041        )];
1042        assert!(matches!(
1043            plan(&spec),
1044            Err(PlanError::Design {
1045                block: 0,
1046                source: DesignError::UnequalLengths { .. }
1047            })
1048        ));
1049    }
1050
1051    #[test]
1052    fn an_action_tick_factor_moves_the_action() {
1053        let mut spec = spec_with_actions();
1054        spec.blocks = vec![block(
1055            DesignKind::Factorial,
1056            vec![
1057                FactorSpec::param("grid_width", values(&["16", "32"])),
1058                FactorSpec::action("second_wave", values(&["10", "90"])),
1059            ],
1060        )];
1061        let plan = plan(&spec).expect("a valid spec");
1062        let ticks: Vec<Vec<u64>> = plan
1063            .configs()
1064            .iter()
1065            .map(|config| config.action_ticks.clone())
1066            .collect();
1067        assert_eq!(
1068            ticks,
1069            [[50, 10], [50, 90], [50, 10], [50, 90]],
1070            "the other action keeps its tick"
1071        );
1072        let run = plan.run(1).expect("run 1 exists");
1073        let moved = plan.run(3).expect("run 3 exists");
1074        assert_eq!(run.seed, moved.seed);
1075        assert_ne!(plan.run_key(&run), plan.run_key(&moved), "the tick is part of the key");
1076        assert!(plan.warnings().is_empty());
1077    }
1078
1079    #[test]
1080    fn a_replay_holds_its_runs_config_seed_and_schedule() {
1081        let mut spec = spec_with_actions();
1082        spec.run.warmup = 5;
1083        spec.seeds.root = 9;
1084        spec.blocks = vec![block(
1085            DesignKind::Factorial,
1086            vec![
1087                FactorSpec::param("grid_width", values(&["16", "32"])),
1088                FactorSpec::action("second_wave", values(&["10"])),
1089            ],
1090        )];
1091        let plan = plan(&spec).expect("a valid spec");
1092        assert_eq!(plan.model(), "sir");
1093        let run = plan.run(3).expect("run 3 exists");
1094        let config = plan.config(run.config_id).expect("the config exists");
1095        let replay = plan.replay(3).expect("run 3 exists");
1096        assert_eq!(replay.model, "sir");
1097        assert_eq!(replay.params, config.params);
1098        assert_eq!(replay.params[2], ParamValue::U32(32));
1099        assert_eq!(replay.seed, run.seed);
1100        assert_eq!(replay.schedule, plan.schedule(config));
1101        let ticks: Vec<u64> = replay.schedule.entries().iter().map(|entry| entry.tick).collect();
1102        assert_eq!(ticks, [10, 50]);
1103        assert_eq!(replay.ticks, 105, "the warm-up is part of the run");
1104        assert_eq!(replay.label, "Sweep run 3: config 1, replicate 1");
1105        assert_eq!(plan.replay(plan.run_count()), None);
1106    }
1107
1108    #[test]
1109    fn a_schedule_orders_by_tick_then_spec_order() {
1110        let mut spec = spec_with_actions();
1111        spec.actions.push(ActionSpec {
1112            name: "vaccination".to_owned(),
1113            ..ActionSpec::new("vaccinate", 20)
1114        });
1115        spec.blocks = vec![block(
1116            DesignKind::Zip,
1117            vec![FactorSpec::action("second_wave", values(&["50", "0"]))],
1118        )];
1119        let plan = plan(&spec).expect("a valid spec");
1120        let order = |config_id: u64| {
1121            let config = plan.config(config_id).expect("the config exists");
1122            plan.schedule(config)
1123                .entries()
1124                .iter()
1125                .map(|Scheduled { index, tick, .. }| (*index, *tick))
1126                .collect::<Vec<_>>()
1127        };
1128        assert_eq!(order(0), [(1, 20), (0, 50), (0, 50)], "two at one tick keep spec order");
1129        assert_eq!(order(1), [(0, 0), (1, 20), (0, 50)]);
1130        assert_eq!(plan.schedule(&plan.configs()[1]).entries()[0].id, "seed_outbreak");
1131    }
1132
1133    #[test]
1134    fn an_action_the_spec_cannot_fire_is_refused() {
1135        let mut spec = spec_with_actions();
1136        spec.actions[1].name = "seed_outbreak".to_owned();
1137        assert_eq!(
1138            plan(&spec),
1139            Err(PlanError::DuplicateActionName {
1140                name: "seed_outbreak".to_owned()
1141            })
1142        );
1143
1144        let mut spec = spec_with_actions();
1145        spec.actions.push(ActionSpec::new("reset", 3));
1146        let error = plan(&spec).expect_err("no action 'reset'");
1147        assert_eq!(
1148            error.to_string(),
1149            "unknown action 'reset', expected one of seed_outbreak, vaccinate"
1150        );
1151
1152        let mut spec = spec_with_actions();
1153        spec.blocks = vec![block(
1154            DesignKind::Factorial,
1155            vec![FactorSpec::action("third_wave", values(&["10"]))],
1156        )];
1157        assert!(matches!(
1158            plan(&spec),
1159            Err(PlanError::Factor {
1160                source: FactorError::UnknownAction { .. },
1161                ..
1162            })
1163        ));
1164
1165        let mut spec = spec_with_actions();
1166        spec.blocks = vec![block(
1167            DesignKind::Factorial,
1168            vec![
1169                FactorSpec::action("second_wave", values(&["10"])),
1170                FactorSpec::action("second_wave", values(&["20"])),
1171            ],
1172        )];
1173        assert!(matches!(
1174            plan(&spec),
1175            Err(PlanError::VariedTwice {
1176                target: FactorTarget::Action(_),
1177                ..
1178            })
1179        ));
1180    }
1181
1182    #[test]
1183    fn an_action_past_the_end_is_a_warning() {
1184        let mut spec = spec_with_actions();
1185        spec.blocks = vec![block(
1186            DesignKind::Factorial,
1187            vec![FactorSpec::action("second_wave", values(&["100", "101", "150"]))],
1188        )];
1189        let plan = plan(&spec).expect("a late action still plans");
1190        assert_eq!(
1191            plan.warnings(),
1192            [PlanWarning::ActionAfterEnd {
1193                name: "second_wave".to_owned(),
1194                latest_tick: 150,
1195                last_tick: 100,
1196                config_count: 2,
1197            }],
1198            "tick 100 is the last tick, and fires after the last step"
1199        );
1200        let lone = PlanWarning::ActionAfterEnd {
1201            name: "second_wave".to_owned(),
1202            latest_tick: 150,
1203            last_tick: 100,
1204            config_count: 1,
1205        };
1206        assert_eq!(
1207            lone.to_string(),
1208            "action 'second_wave' is due after the last tick 100 in 1 config (latest tick 150) and will not run there"
1209        );
1210    }
1211
1212    #[test]
1213    fn a_stop_over_an_unknown_label_is_refused() {
1214        let mut spec = SweepSpec::new("sir");
1215        spec.run.stop = Some(StopSpec::parse("Infected <= 0", 5).expect("a well-formed condition"));
1216        assert!(plan(&spec).is_ok());
1217        spec.run.stop = Some(StopSpec::parse("Susceptible <= 0", 5).expect("a well-formed condition"));
1218        assert!(matches!(
1219            plan(&spec),
1220            Err(PlanError::Stop(StopError::UnknownColumn { .. }))
1221        ));
1222    }
1223
1224    /// A comparison that a form builds without the parser can hold a threshold that is not finite.
1225    #[test]
1226    fn a_threshold_that_is_not_finite_is_refused_at_plan_time() {
1227        let mut spec = SweepSpec::new("sir");
1228        let mut stop = StopSpec::parse("Infected <= 0", 0).expect("a well-formed condition");
1229        stop.comparison.threshold = f64::INFINITY;
1230        spec.run.stop = Some(stop);
1231        let error = plan(&spec).expect_err("an infinite threshold");
1232        assert!(
1233            matches!(
1234                error,
1235                PlanError::Measure(MeasureError::Stop(StopError::NonFiniteThreshold { .. }))
1236            ),
1237            "{error:?}"
1238        );
1239
1240        spec.run.stop = None;
1241        let mut first = "Infected:first>=10"
1242            .parse::<ReducerSpec>()
1243            .expect("a well-formed reducer");
1244        if let ReducerKind::FirstCrossing(comparison) = &mut first.kind {
1245            comparison.threshold = f64::NEG_INFINITY;
1246        }
1247        spec.measure.reducers = vec![first];
1248        let error = plan(&spec).expect_err("an infinite threshold");
1249        assert!(
1250            matches!(
1251                error,
1252                PlanError::Measure(MeasureError::Reducer(ReducerError::Comparison { .. }))
1253            ),
1254            "{error:?}"
1255        );
1256    }
1257
1258    #[test]
1259    fn a_sampled_block_records_its_design_seed() {
1260        let mut spec = spec_with_actions();
1261        spec.seeds.root = 42;
1262        let sampled = |design_seed| BlockSpec {
1263            design_seed,
1264            ..block(
1265                DesignKind::LatinHypercube { samples: 6 },
1266                vec![
1267                    FactorSpec::param(
1268                        "infection_rate",
1269                        LevelSpec::Range {
1270                            min: 0.1,
1271                            max: 0.9,
1272                            step: None,
1273                        },
1274                    ),
1275                    FactorSpec::action(
1276                        "second_wave",
1277                        LevelSpec::Range {
1278                            min: 0.0,
1279                            max: 100.0,
1280                            step: None,
1281                        },
1282                    ),
1283                ],
1284            )
1285        };
1286        spec.blocks = vec![block(DesignKind::Zip, Vec::new()), sampled(None), sampled(Some(7))];
1287        let plan = plan(&spec).expect("a valid spec");
1288        let seeds: Vec<Option<u64>> = plan.blocks().iter().map(|block| block.design_seed).collect();
1289        assert_eq!(seeds, [None, Some(design_seed(42, 1)), Some(7)]);
1290        assert_eq!(plan.configs().len(), 13);
1291        assert_eq!(plan.blocks()[2].configs, 7..13);
1292    }
1293
1294    #[test]
1295    fn a_table_block_reads_its_configs() {
1296        let mut spec = spec_with_actions();
1297        spec.fixed = vec![("recovery_rate".to_owned(), "0.1".to_owned())];
1298        let table = "infection_rate,action.second_wave\n0.25,5\n0.5,6\n";
1299        spec.blocks = vec![block(DesignKind::Table { text: table.to_owned() }, Vec::new())];
1300        let table_plan = plan(&spec).expect("a valid spec");
1301        assert_eq!(table_plan.configs().len(), 2);
1302        assert_eq!(table_plan.configs()[1].params[0], ParamValue::F32(0.5));
1303        assert_eq!(
1304            table_plan.configs()[1].params[1],
1305            ParamValue::F32(0.1),
1306            "fixed values fill the rest"
1307        );
1308        assert_eq!(table_plan.configs()[1].action_ticks, [50, 6]);
1309        assert_eq!(table_plan.blocks()[0].design_seed, None);
1310
1311        let mut listed = spec.clone();
1312        listed.blocks[0].factors = vec![FactorSpec::param("grid_width", values(&["16"]))];
1313        assert_eq!(plan(&listed), Err(PlanError::TableWithFactors { block: 0 }));
1314
1315        let mut fixed = spec.clone();
1316        fixed.fixed = vec![("infection_rate".to_owned(), "0.1".to_owned())];
1317        assert!(matches!(plan(&fixed), Err(PlanError::FixedAndVaried { .. })));
1318
1319        let mut unknown = spec;
1320        unknown.blocks[0].design = DesignKind::Table {
1321            text: "speed\n1\n".to_owned(),
1322        };
1323        assert!(matches!(
1324            plan(&unknown),
1325            Err(PlanError::Table {
1326                block: 0,
1327                source: DesignTableError::UnknownColumn { .. }
1328            })
1329        ));
1330    }
1331
1332    #[test]
1333    fn shards_partition_the_runs() {
1334        let mut spec = SweepSpec::new("sir");
1335        spec.run.replicates = 3;
1336        spec.blocks = vec![block(
1337            DesignKind::Factorial,
1338            vec![FactorSpec::param("grid_width", values(&["16", "32", "48", "64", "80"]))],
1339        )];
1340        let plan = plan(&spec).expect("a valid spec");
1341        let every: Vec<u64> = plan.runs().map(|run| run.run_id).collect();
1342        assert_eq!(
1343            plan.runs_in_shard(Shard::WHOLE).collect::<Vec<_>>(),
1344            plan.runs().collect::<Vec<_>>()
1345        );
1346        for count in 1..=17 {
1347            let mut seen = BTreeSet::new();
1348            for index in 0..count {
1349                let shard = Shard::new(index, count).expect("a valid shard");
1350                let runs: Vec<u64> = plan.runs_in_shard(shard).map(|run| run.run_id).collect();
1351                assert_eq!(runs.len() as u64, shard.run_count(plan.run_count()), "shard {shard}");
1352                assert!(
1353                    runs.iter()
1354                        .all(|&run_id| run_id % count == index && shard.contains(run_id))
1355                );
1356                assert!(runs.windows(2).all(|pair| pair[0] < pair[1]), "in run order");
1357                for run_id in runs {
1358                    assert!(seen.insert(run_id), "run {run_id} is in one shard only");
1359                }
1360            }
1361            assert_eq!(
1362                seen.into_iter().collect::<Vec<_>>(),
1363                every,
1364                "{count} shards cover every run"
1365            );
1366        }
1367        let second_run = plan.runs_in_shard(Shard::new(1, 4).expect("a valid shard")).nth(1);
1368        assert_eq!(second_run, plan.run(5), "shard runs carry their plan's seeds");
1369    }
1370
1371    #[test]
1372    fn a_shard_reads_as_index_over_count() {
1373        assert_eq!("2/5".parse::<Shard>(), Shard::new(2, 5));
1374        assert_eq!("0/1".parse::<Shard>(), Ok(Shard::WHOLE));
1375        assert_eq!(Shard::new(3, 8).map(|shard| shard.to_string()), Ok("3/8".to_owned()));
1376        assert_eq!("1/0".parse::<Shard>(), Err(ShardError::ZeroCount));
1377        assert_eq!(
1378            "4/4".parse::<Shard>(),
1379            Err(ShardError::IndexPastCount { index: 4, count: 4 })
1380        );
1381        for bad in ["", "1", "1/", "a/2", "-1/2", "1/2/3"] {
1382            assert_eq!(
1383                bad.parse::<Shard>(),
1384                Err(ShardError::BadText { raw: bad.to_owned() }),
1385                "{bad}"
1386            );
1387        }
1388        assert_eq!(Shard::WHOLE.run_count(0), 0);
1389        assert_eq!(Shard::new(5, 6).map(|shard| shard.run_count(5)), Ok(0));
1390    }
1391}