Skip to main content

henad_explore/
result_set.rs

1//! Results of a sweep or search read back from its files, for a host to show and replay.
2//!
3//! A result set reads `manifest.json` and `runs.csv`, and `series.csv` and a search's tables when there are any.
4//! `summary.csv` is left unread. The runs rebuild it. Only complete records are read, so a directory that a stopped
5//! sweep left behind opens with the runs it wrote. Series are held run by run in file order while they fit a byte
6//! budget. From the first run past it, runs are held without their series.
7
8use std::collections::BTreeMap;
9#[cfg(not(target_arch = "wasm32"))]
10use std::collections::BTreeSet;
11use std::fmt;
12use std::io::{self, BufRead};
13use std::path::{Path, PathBuf};
14
15use henad_core::explore::fingerprint::schema_hash;
16use henad_core::explore::measure::SeriesBuffer;
17use henad_core::explore::outcome::{PlannedRun, RunOutcome};
18use henad_core::explore::plan::{Config, ModelSchema, Plan, PlanError};
19use henad_core::explore::replay::Replay;
20use henad_core::explore::search::SearchSpecError;
21use henad_core::explore::spec::SweepSpec;
22
23use henad_core::explore::value::parse_value;
24
25use crate::output::manifest::{BuildRole, Manifest, ManifestError, ManifestMode, ManifestStatus, RecordedBuild};
26use crate::output::read::{ReadError, parse_one, record_ends};
27use crate::output::runs_csv::{ID_COLUMNS, NOTE_COLUMN, OUTCOME_COLUMNS};
28use crate::output::search_tables::{
29    ARCHIVE_ID_COLUMNS, EVALUATION_ID_COLUMNS, GENERATION_COLUMNS, OBJECTIVE_BATCH_COLUMNS, PATTERN_BATCH_COLUMNS,
30    RANKING_ID_COLUMNS, SearchHistory,
31};
32use crate::output::series_csv::SERIES_ID_COLUMNS;
33use crate::output::{
34    ARCHIVE_FILE, BATCHES_FILE, BEST_FILE, EVALUATIONS_FILE, GENERATIONS_FILE, MANIFEST_FILE, RUNS_FILE, SERIES_FILE,
35};
36use crate::search_run::{SearchPlan, SearchPlanError};
37use crate::spec_file::{SpecFile, SpecFileError};
38use crate::sweep::hex;
39
40/// Results of one sweep, read from an output directory or from its files' bytes.
41#[derive(Debug, Clone)]
42pub struct ResultSet {
43    manifest: Manifest,
44    spec: SweepSpec,
45    /// Directory the files were read from, `None` for files passed as bytes.
46    dir: Option<PathBuf>,
47    /// Names of the parameter and action columns of `runs.csv`, in order.
48    value_columns: Vec<String>,
49    reducer_columns: Vec<String>,
50    stat_columns: Vec<String>,
51    runs: Vec<RunRow>,
52    /// Position in `runs` of each run, by run id.
53    positions: BTreeMap<u64, usize>,
54    /// Size in bytes of the held series.
55    series_bytes: usize,
56    /// Text of each search table read, by file name. Empty for a sweep.
57    search_tables: BTreeMap<&'static str, String>,
58}
59
60/// Tables a search writes beside `runs.csv`, in the order a result set lists them.
61const SEARCH_FILES: [&str; 5] = [
62    EVALUATIONS_FILE,
63    BATCHES_FILE,
64    GENERATIONS_FILE,
65    BEST_FILE,
66    ARCHIVE_FILE,
67];
68
69/// One run as `runs.csv` records it.
70#[derive(Debug, Clone, PartialEq)]
71pub struct RunRow {
72    /// The run's row, with its series when [`Self::series_held`] is set.
73    ///
74    /// A timing written as an empty cell is read as a value that is not finite.
75    pub outcome: RunOutcome,
76    /// Index of the block the run's config comes from.
77    pub block: usize,
78    /// Parameter values and action ticks as `runs.csv` writes them, one per [`ResultSet::value_columns`] entry.
79    pub values: Vec<String>,
80    /// Whether `outcome` holds the run's whole series.
81    ///
82    /// A run past the series budget, or read without a `series.csv`, holds no series.
83    pub series_held: bool,
84}
85
86impl ResultSet {
87    /// Reads the output directory at `dir`, holding at most `series_budget` bytes of series.
88    ///
89    /// # Errors
90    ///
91    /// Returns [`ResultSetError`] when the manifest or `runs.csv` is missing or cannot be read, the manifest's spec
92    /// cannot be read back or the search it records has a setting out of range, or a complete record of a table is
93    /// not one the sweep writes.
94    #[cfg(not(target_arch = "wasm32"))]
95    pub fn open_dir(dir: &Path, series_budget: usize) -> Result<Self, ResultSetError> {
96        let manifest_path = dir.join(MANIFEST_FILE);
97        if !manifest_path.exists() {
98            return Err(ResultSetError::Missing { file: MANIFEST_FILE });
99        }
100        let manifest = Manifest::read(&manifest_path).map_err(ResultSetError::Manifest)?;
101        let (runs_path, series_path) = crate::output::table_paths(dir);
102        let runs = match std::fs::read(&runs_path) {
103            Ok(runs) => runs,
104            Err(error) if error.kind() == io::ErrorKind::NotFound => {
105                return Err(ResultSetError::Missing { file: RUNS_FILE });
106            }
107            Err(source) => {
108                return Err(ResultSetError::Table(ReadError::Io {
109                    path: runs_path,
110                    source,
111                }));
112            }
113        };
114        let mut set = Self::from_tables(manifest, &runs, &runs_path)?;
115        set.dir = Some(dir.to_owned());
116        for file in SEARCH_FILES {
117            let path = dir.join(file);
118            match std::fs::read(&path) {
119                // A stopped search can leave a table without a complete header line. That table is skipped, as
120                // `from_files` skips it.
121                Ok(bytes) if !bytes.contains(&b'\n') => {}
122                Ok(bytes) => {
123                    set.search_tables
124                        .insert(file, String::from_utf8_lossy(&bytes).into_owned());
125                }
126                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
127                Err(source) => return Err(ResultSetError::Table(ReadError::Io { path, source })),
128            }
129        }
130        match std::fs::File::open(&series_path) {
131            Ok(file) => set.read_series(io::BufReader::new(file), &series_path, series_budget)?,
132            Err(error) if error.kind() == io::ErrorKind::NotFound => set.mark_series_missing(),
133            Err(source) => {
134                return Err(ResultSetError::Table(ReadError::Io {
135                    path: series_path,
136                    source,
137                }));
138            }
139        }
140        Ok(set)
141    }
142
143    /// Reads the files `files` of one output directory, each as its name and bytes, holding at most `series_budget`
144    /// bytes of series.
145    ///
146    /// A file is known by its contents, so a browser's renamed download such as `runs (1).csv` is still read. The one
147    /// JSON file is the manifest, and a CSV file is `runs.csv`, `series.csv` or a search table by its header. The
148    /// manifest and `runs.csv` are needed, `series.csv` and the search tables are read when present, and every other
149    /// file is left unread.
150    ///
151    /// # Errors
152    ///
153    /// Returns [`ResultSetError`] when the manifest or `runs.csv` is missing or cannot be read, two files hold the
154    /// same table, the manifest's spec cannot be read back or the search it records has a setting out of range, or a
155    /// complete record of a table is not one the sweep writes.
156    pub fn from_files(files: Vec<(String, Vec<u8>)>, series_budget: usize) -> Result<Self, ResultSetError> {
157        let mut tables: BTreeMap<&'static str, Vec<u8>> = BTreeMap::new();
158        for (name, bytes) in files {
159            let Some(table) = picked_table(&name, &bytes) else {
160                continue;
161            };
162            if tables.insert(table, bytes).is_some() {
163                return Err(ResultSetError::Duplicate { file: table });
164            }
165        }
166        let manifest_bytes = tables
167            .get(MANIFEST_FILE)
168            .ok_or(ResultSetError::Missing { file: MANIFEST_FILE })?;
169        let manifest = Manifest::parse(&String::from_utf8_lossy(manifest_bytes), Path::new(MANIFEST_FILE))
170            .map_err(ResultSetError::Manifest)?;
171        let runs = tables
172            .get(RUNS_FILE)
173            .ok_or(ResultSetError::Missing { file: RUNS_FILE })?;
174        let mut set = Self::from_tables(manifest, runs, Path::new(RUNS_FILE))?;
175        match tables.get(SERIES_FILE) {
176            Some(series) => set.read_series(series.as_slice(), Path::new(SERIES_FILE), series_budget)?,
177            None => set.mark_series_missing(),
178        }
179        for file in SEARCH_FILES {
180            if let Some(bytes) = tables.get(file) {
181                set.search_tables
182                    .insert(file, String::from_utf8_lossy(bytes).into_owned());
183            }
184        }
185        Ok(set)
186    }
187
188    /// Returns the set of `manifest` and `runs`, the bytes of the `runs.csv` at `runs_path`, with no series read.
189    fn from_tables(manifest: Manifest, runs: &[u8], runs_path: &Path) -> Result<Self, ResultSetError> {
190        let spec = SpecFile::from_json(&manifest.spec)
191            .and_then(SpecFile::into_spec)
192            .map_err(ResultSetError::Spec)?;
193        if let Some(search) = &spec.search {
194            search.check().map_err(ResultSetError::Search)?;
195        }
196        let stat_columns = manifest.columns.stats.clone();
197        let (column_names, records) = read_runs(runs, runs_path, stat_columns.len())?;
198        let positions = records
199            .iter()
200            .enumerate()
201            .map(|(position, run)| (run.outcome.run.run_id, position))
202            .collect();
203        Ok(Self {
204            manifest,
205            spec,
206            dir: None,
207            value_columns: column_names.value_columns,
208            reducer_columns: column_names.reducer_columns,
209            stat_columns,
210            runs: records,
211            positions,
212            series_bytes: 0,
213            search_tables: BTreeMap::new(),
214        })
215    }
216
217    /// Reads `series`, the lines of the `series.csv` at `path`, into the runs, while the series fit `series_budget`
218    /// bytes.
219    ///
220    /// Rows of a run that `runs.csv` does not hold are left out, and so is a partial last line. A run with any row
221    /// past the budget is held without its series, even when some of its rows came before.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`ResultSetError::Table`] when `series` cannot be read, its header does not list the manifest's stat
226    /// columns, or a row is not one the sweep writes.
227    fn read_series(
228        &mut self,
229        mut series: impl BufRead,
230        path: &Path,
231        series_budget: usize,
232    ) -> Result<(), ResultSetError> {
233        let width = self.stat_columns.len();
234        let row_bytes = (width + 1) * size_of::<f64>();
235        let mut line = Vec::new();
236        if !read_line(&mut series, &mut line, path)? {
237            self.mark_series_missing();
238            return Ok(());
239        }
240        check_series_header(&line, &self.stat_columns, path)?;
241        let mut budget_spent = false;
242        let mut values = Vec::with_capacity(width);
243        let mut record_number = 1;
244        // Position of the run the last row went to. Its series is trimmed to its rows once another run's rows start.
245        let mut last_position = None;
246        while read_line(&mut series, &mut line, path)? {
247            record_number += 1;
248            let (run_id, tick) = parse_series_row(&line, width, &mut values, path, record_number)?;
249            let Some(&position) = self.positions.get(&run_id) else {
250                continue;
251            };
252            if let Some(last) = last_position.replace(position)
253                && last != position
254            {
255                self.runs[last].outcome.series.shrink_to_fit();
256            }
257            let run = &mut self.runs[position];
258            if !run.series_held {
259                continue;
260            }
261            // Past the budget a run drops the rows it holds. Otherwise a run with rows on both sides of the crossing
262            // keeps part of its series.
263            if budget_spent || self.series_bytes + row_bytes > series_budget {
264                budget_spent = true;
265                self.series_bytes -= run.outcome.series.len() * row_bytes;
266                run.outcome.series = SeriesBuffer::new(width);
267                run.series_held = false;
268                continue;
269            }
270            self.series_bytes += row_bytes;
271            run.outcome.series.push(tick, &values);
272        }
273        if let Some(last) = last_position {
274            self.runs[last].outcome.series.shrink_to_fit();
275        }
276        Ok(())
277    }
278
279    /// Marks every run as held without its series.
280    fn mark_series_missing(&mut self) {
281        for run in &mut self.runs {
282            run.series_held = false;
283        }
284    }
285
286    /// Manifest as `manifest.json` holds it.
287    pub fn manifest(&self) -> &Manifest {
288        &self.manifest
289    }
290
291    /// Returns every distinct build recorded for `role` by the manifest's sessions that wrote runs, in session order,
292    /// as [`Manifest::recorded_builds`] lists them.
293    ///
294    /// A session of a 0.2 manifest records no builds, and is treated as the engine build of its own commit and the
295    /// manifest's engine version, with no model build.
296    pub fn recorded_builds(&self, role: BuildRole) -> Vec<RecordedBuild> {
297        self.manifest.recorded_builds(role)
298    }
299
300    /// Spec the sweep ran, as its manifest records it.
301    pub fn spec(&self) -> &SweepSpec {
302        &self.spec
303    }
304
305    /// Directory the files were read from, `None` for files passed as bytes.
306    pub fn dir(&self) -> Option<&Path> {
307        self.dir.as_deref()
308    }
309
310    /// Names of the parameter and action columns of `runs.csv`, in order.
311    ///
312    /// Empty when `runs.csv` has no header yet, as in a directory whose sweep has written no run.
313    pub fn value_columns(&self) -> &[String] {
314        &self.value_columns
315    }
316
317    /// Names of the reducer columns of `runs.csv`, one per value of a run's [`RunOutcome::reducers`].
318    ///
319    /// Empty when `runs.csv` has no header yet.
320    pub fn reducer_columns(&self) -> &[String] {
321        &self.reducer_columns
322    }
323
324    /// Names of the stat columns of `series.csv`, one per value of a series row.
325    pub fn stat_columns(&self) -> &[String] {
326        &self.stat_columns
327    }
328
329    /// Runs in the order `runs.csv` lists them.
330    pub fn runs(&self) -> &[RunRow] {
331        &self.runs
332    }
333
334    /// Returns run `run_id`, or `None` when `runs.csv` holds no such run.
335    pub fn run(&self, run_id: u64) -> Option<&RunRow> {
336        self.runs.get(*self.positions.get(&run_id)?)
337    }
338
339    /// Number of runs held with their series.
340    pub fn held_series_count(&self) -> usize {
341        self.runs.iter().filter(|run| run.series_held).count()
342    }
343
344    /// Returns whether the sweep ran every run of its shard.
345    ///
346    /// A directory a sweep left `running`, `aborted` or `failed`, or a merge left `incomplete`, can be resumed.
347    pub fn is_complete(&self) -> bool {
348        self.manifest.status == ManifestStatus::Complete
349    }
350
351    /// Returns whether `schema` declares the parameters, stats and actions the sweep ran with.
352    ///
353    /// A replay of a run through a model that does not match might differ from the run. A host passes
354    /// `entry.schema()` for a [`ModelEntry`](henad_compute::entry::ModelEntry).
355    pub fn schema_matches(&self, schema: ModelSchema<'_>) -> bool {
356        schema.id == self.manifest.model.id && hex(schema_hash(&schema)) == self.manifest.model.schema_hash
357    }
358
359    /// Plans the recorded spec against `schema`.
360    ///
361    /// # Errors
362    ///
363    /// Returns [`PlanError`] when the model rejects the spec.
364    pub fn plan(&self, schema: ModelSchema<'_>) -> Result<Plan, PlanError> {
365        self.spec.plan(&schema)
366    }
367
368    /// Returns the [`Replay`] of run `run_id` through the model `schema` declares.
369    ///
370    /// The spec is planned afresh on each call. A host that replays several runs of a sweep plans once with
371    /// [`Self::plan`] and calls [`Plan::replay`]. A search's run is rebuilt from the config its row records.
372    ///
373    /// # Errors
374    ///
375    /// Returns [`ResultReplayError`] when the model rejects the spec, `runs.csv` holds no run `run_id`, or the plan
376    /// assigns the run a different config, replicate, seed or run key than its row. The run key covers the config's
377    /// parameter values and action ticks. It also hashes the model's declarations, so it is compared only while
378    /// [`Self::schema_matches`] holds for `schema`.
379    pub fn replay(&self, schema: ModelSchema<'_>, run_id: u64) -> Result<Replay, ResultReplayError> {
380        let recorded = self.run(run_id).ok_or(ResultReplayError::UnknownRun { run_id })?;
381        if self.is_search() {
382            return self.search_replay(schema, recorded);
383        }
384        let plan = self.plan(schema).map_err(ResultReplayError::Plan)?;
385        match plan.run(run_id) {
386            Some(planned)
387                if planned == recorded.outcome.run && self.key_matches(schema, plan.run_key(&planned), recorded) =>
388            {
389                plan.replay(run_id).ok_or(ResultReplayError::Mismatch { run_id })
390            }
391            _ => Err(ResultReplayError::Mismatch { run_id }),
392        }
393    }
394
395    /// Returns the [`Replay`] of `recorded`, a run of a search, from the config its row records.
396    ///
397    /// Each value is found by its column's name, a parameter's id or an action's `action.<name>`, so a model that
398    /// reorders its parameters still reads each value under its own id.
399    fn search_replay(&self, schema: ModelSchema<'_>, recorded: &RunRow) -> Result<Replay, ResultReplayError> {
400        let run = recorded.outcome.run;
401        let mismatch = || ResultReplayError::Mismatch { run_id: run.run_id };
402        let plan = SearchPlan::new(&self.spec, &schema).map_err(ResultReplayError::Search)?;
403        let actions = plan.base().actions();
404        if recorded.values.len() != schema.params.len() + actions.len()
405            || self.value_columns.len() != recorded.values.len()
406        {
407            return Err(mismatch());
408        }
409        let value_of = |column: &str| {
410            let position = self.value_columns.iter().position(|name| name == column)?;
411            recorded.values.get(position)
412        };
413        let config = Config {
414            block: recorded.block,
415            params: schema
416                .params
417                .iter()
418                .map(|descriptor| parse_value(&descriptor.kind, value_of(descriptor.id)?).ok())
419                .collect::<Option<_>>()
420                .ok_or_else(mismatch)?,
421            action_ticks: actions
422                .iter()
423                .map(|action| value_of(&action.column_name())?.parse().ok())
424                .collect::<Option<_>>()
425                .ok_or_else(mismatch)?,
426        };
427        if !self.key_matches(schema, plan.run_key(&run, &config), recorded) {
428            return Err(mismatch());
429        }
430        Ok(plan.replay(&run, &config))
431    }
432
433    /// Returns whether `key`, the key that planning with `schema` assigns to `recorded`, matches the key its row
434    /// holds. Any key matches while [`Self::schema_matches`] fails for `schema`.
435    fn key_matches(&self, schema: ModelSchema<'_>, key: u64, recorded: &RunRow) -> bool {
436        !self.schema_matches(schema) || key == recorded.outcome.run_key
437    }
438
439    /// Returns whether the results come from a search.
440    pub fn is_search(&self) -> bool {
441        self.manifest.mode == ManifestMode::Search
442    }
443
444    /// Returns the text of the search table `file`, such as `evaluations.csv`, or `None` when the results hold no such
445    /// table.
446    pub fn search_table(&self, file: &str) -> Option<&str> {
447        self.search_tables.get(file).map(String::as_str)
448    }
449
450    /// Returns the course of a search batch by batch, from its tables, or `None` for a sweep.
451    ///
452    /// # Errors
453    ///
454    /// Returns [`ResultSetError::Table`] when a search table cannot be read.
455    pub fn search_history(&self) -> Result<Option<SearchHistory>, ResultSetError> {
456        if !self.is_search() {
457            return Ok(None);
458        }
459        SearchHistory::read(
460            self.search_table(BATCHES_FILE),
461            self.search_table(GENERATIONS_FILE),
462            self.search_table(EVALUATIONS_FILE),
463            self.spec.search.as_ref(),
464        )
465        .map(Some)
466        .map_err(ResultSetError::Table)
467    }
468
469    /// Reads the whole series of each run of `run_ids` from the directory the set was read from, while the series
470    /// fit `series_budget` bytes, as [`read_directory_series`] does.
471    ///
472    /// # Errors
473    ///
474    /// Returns [`ResultSetError::Missing`] for a set read from bytes, and [`ResultSetError::Table`] when `series.csv`
475    /// cannot be read or its header does not list the stat columns.
476    #[cfg(not(target_arch = "wasm32"))]
477    pub fn read_run_series(
478        &self,
479        run_ids: &BTreeSet<u64>,
480        series_budget: usize,
481    ) -> Result<DirectorySeries, ResultSetError> {
482        let dir = self
483            .dir
484            .as_deref()
485            .ok_or(ResultSetError::Missing { file: SERIES_FILE })?;
486        let held: BTreeSet<u64> = run_ids
487            .iter()
488            .copied()
489            .filter(|run_id| self.positions.contains_key(run_id))
490            .collect();
491        read_directory_series(dir, &self.stat_columns, &held, series_budget)
492    }
493
494    /// Returns the runs in the order `runs.csv` lists them, and drops the rest of the set.
495    pub fn into_runs(self) -> Vec<RunRow> {
496        self.runs
497    }
498}
499
500/// Series of runs read from the `series.csv` of an output directory.
501#[cfg(not(target_arch = "wasm32"))]
502#[derive(Debug, Clone, Default, PartialEq)]
503pub struct DirectorySeries {
504    /// Whole series of each run read, by run id.
505    pub series: BTreeMap<u64, SeriesBuffer>,
506    /// Runs left out once the series passed the budget.
507    ///
508    /// A run that was requested and found in neither map has no rows in `series.csv`.
509    pub dropped_runs: BTreeSet<u64>,
510}
511
512/// Reads the whole series of each run of `run_ids` from the `series.csv` of the output directory `dir`, whose stat
513/// columns are `stat_columns`, while the series fit `series_budget` bytes.
514///
515/// A series counts its bytes as [`ResultSet`] does. From the first row past the budget, every run not yet read in
516/// full is left out.
517///
518/// # Errors
519///
520/// Returns [`ResultSetError::Table`] when `series.csv` cannot be read, its header does not list `stat_columns`, or it
521/// holds a row that is not as wide as the header.
522#[cfg(not(target_arch = "wasm32"))]
523pub fn read_directory_series(
524    dir: &Path,
525    stat_columns: &[String],
526    run_ids: &BTreeSet<u64>,
527    series_budget: usize,
528) -> Result<DirectorySeries, ResultSetError> {
529    let (_, path) = crate::output::table_paths(dir);
530    let file = std::fs::File::open(&path).map_err(|source| {
531        ResultSetError::Table(ReadError::Io {
532            path: path.clone(),
533            source,
534        })
535    })?;
536    let mut lines = io::BufReader::new(file);
537    let mut read = DirectorySeries::default();
538    let stat_width = stat_columns.len();
539    let row_bytes = (stat_width + 1) * size_of::<f64>();
540    let mut series_bytes = 0;
541    let mut budget_spent = false;
542    let mut line = Vec::new();
543    let mut values = Vec::with_capacity(stat_width);
544    let mut record_number = 1;
545    // Run the last row went to. Its series is trimmed to its rows once another run's rows start.
546    let mut last_run = None;
547    if !read_line(&mut lines, &mut line, &path)? {
548        return Ok(read);
549    }
550    check_series_header(&line, stat_columns, &path)?;
551    while read_line(&mut lines, &mut line, &path)? {
552        record_number += 1;
553        let (run_id, tick) = parse_series_row(&line, stat_width, &mut values, &path, record_number)?;
554        if !run_ids.contains(&run_id) || read.dropped_runs.contains(&run_id) {
555            continue;
556        }
557        if let Some(last) = last_run.replace(run_id)
558            && last != run_id
559            && let Some(series) = read.series.get_mut(&last)
560        {
561            series.shrink_to_fit();
562        }
563        budget_spent |= series_bytes + row_bytes > series_budget;
564        if budget_spent {
565            if let Some(partial) = read.series.remove(&run_id) {
566                series_bytes -= partial.len() * row_bytes;
567            }
568            read.dropped_runs.insert(run_id);
569            continue;
570        }
571        series_bytes += row_bytes;
572        read.series
573            .entry(run_id)
574            .or_insert_with(|| SeriesBuffer::new(stat_width))
575            .push(tick, &values);
576    }
577    if let Some(series) = last_run.and_then(|last| read.series.get_mut(&last)) {
578        series.shrink_to_fit();
579    }
580    Ok(read)
581}
582
583/// Returns the output directory table that the picked file `name` holds, judged from its `bytes`, or `None` for any
584/// other file.
585fn picked_table(name: &str, bytes: &[u8]) -> Option<&'static str> {
586    let extension = Path::new(name)
587        .extension()
588        .map(|extension| extension.to_ascii_lowercase());
589    if extension.as_ref().is_some_and(|extension| extension == "json") {
590        return Some(MANIFEST_FILE);
591    }
592    let header_end = bytes.iter().position(|&byte| byte == b'\n').unwrap_or(bytes.len());
593    let header = String::from_utf8_lossy(&bytes[..header_end]);
594    let header: Vec<&str> = header.trim_end_matches('\r').split(',').collect();
595    if header.starts_with(&ID_COLUMNS) {
596        Some(RUNS_FILE)
597    } else if header.starts_with(&SERIES_ID_COLUMNS) {
598        Some(SERIES_FILE)
599    } else if header.starts_with(&EVALUATION_ID_COLUMNS) {
600        Some(EVALUATIONS_FILE)
601    } else if header == OBJECTIVE_BATCH_COLUMNS || header == PATTERN_BATCH_COLUMNS {
602        Some(BATCHES_FILE)
603    } else if header == GENERATION_COLUMNS {
604        Some(GENERATIONS_FILE)
605    } else if header.starts_with(&RANKING_ID_COLUMNS) {
606        Some(BEST_FILE)
607    } else if header.starts_with(&ARCHIVE_ID_COLUMNS) {
608        Some(ARCHIVE_FILE)
609    } else {
610        None
611    }
612}
613
614/// Checks that `line`, the header line of the `series.csv` at `path`, lists the id columns and then `stat_columns`.
615fn check_series_header(line: &[u8], stat_columns: &[String], path: &Path) -> Result<(), ResultSetError> {
616    let table_error = ResultSetError::Table;
617    let text = String::from_utf8_lossy(line);
618    let header = parse_one(&text, 0..text.len(), path, 1).map_err(table_error)?;
619    let expected = SERIES_ID_COLUMNS.len() + stat_columns.len();
620    if header.len() != expected {
621        return Err(table_error(ReadError::FieldCount {
622            path: path.to_owned(),
623            record_number: 1,
624            found: header.len(),
625            expected,
626        }));
627    }
628    let (ids, stats) = header.split_at(SERIES_ID_COLUMNS.len());
629    for (&column, name) in SERIES_ID_COLUMNS.iter().zip(ids) {
630        if name != column {
631            return Err(table_error(ReadError::MissingColumn {
632                path: path.to_owned(),
633                column,
634            }));
635        }
636    }
637    for (column, name) in stat_columns.iter().zip(stats) {
638        if name != column {
639            return Err(table_error(ReadError::BadField {
640                path: path.to_owned(),
641                record_number: 1,
642                column: column.clone(),
643                text: name.clone(),
644            }));
645        }
646    }
647    Ok(())
648}
649
650/// Reads the next complete line of `lines` into `line`, and returns whether there was a complete line.
651///
652/// A last line without a line feed is partial, and is treated as absent.
653fn read_line(lines: &mut impl BufRead, line: &mut Vec<u8>, path: &Path) -> Result<bool, ResultSetError> {
654    line.clear();
655    let read = lines.read_until(b'\n', line).map_err(|source| {
656        ResultSetError::Table(ReadError::Io {
657            path: path.to_owned(),
658            source,
659        })
660    })?;
661    Ok(read > 0 && line.ends_with(b"\n"))
662}
663
664/// Reads the run id, tick and `width` values of the series row `line` into `values`, an empty cell as a value that is
665/// not finite.
666///
667/// Returns the run id and the tick.
668fn parse_series_row(
669    line: &[u8],
670    width: usize,
671    values: &mut Vec<f64>,
672    path: &Path,
673    record_number: usize,
674) -> Result<(u64, u64), ResultSetError> {
675    let text = String::from_utf8_lossy(line);
676    let text = text.trim_end_matches(['\n', '\r']);
677    let bad_field = |column: &str, field: &str| {
678        ResultSetError::Table(ReadError::BadField {
679            path: path.to_owned(),
680            record_number,
681            column: column.to_owned(),
682            text: field.to_owned(),
683        })
684    };
685    let mut fields = text.split(',');
686    let mut number = |column: &str| {
687        let field = fields.next().unwrap_or_default();
688        field.parse::<u64>().ok().ok_or_else(|| bad_field(column, field))
689    };
690    let run_id = number("run_id")?;
691    let tick = number("tick")?;
692    values.clear();
693    for field in fields {
694        let value = if field.is_empty() {
695            f64::NAN
696        } else {
697            field.parse().ok().ok_or_else(|| bad_field("value", field))?
698        };
699        values.push(value);
700    }
701    if values.len() != width {
702        return Err(ResultSetError::Table(ReadError::FieldCount {
703            path: path.to_owned(),
704            record_number,
705            found: values.len() + 2,
706            expected: width + 2,
707        }));
708    }
709    Ok((run_id, tick))
710}
711
712/// Names of the columns of a `runs.csv` that hold a config's values and a run's reducers.
713struct RunsColumnNames {
714    value_columns: Vec<String>,
715    reducer_columns: Vec<String>,
716}
717
718/// Reads the complete records of `bytes`, a `runs.csv` read from `path`, as runs whose series are `width` wide.
719///
720/// A file without a complete header line holds no runs and no columns. A sweep's header reaches the file with its
721/// first run.
722fn read_runs(bytes: &[u8], path: &Path, width: usize) -> Result<(RunsColumnNames, Vec<RunRow>), ResultSetError> {
723    let table_error = ResultSetError::Table;
724    let ends = record_ends(bytes);
725    let complete = ends.last().copied().unwrap_or(0);
726    let text = std::str::from_utf8(&bytes[..complete]).map_err(|error| {
727        table_error(ReadError::Io {
728            path: path.to_owned(),
729            source: io::Error::new(io::ErrorKind::InvalidData, error),
730        })
731    })?;
732    let Some((&header_end, record_ends)) = ends.split_first() else {
733        let columns = RunsColumnNames {
734            value_columns: Vec::new(),
735            reducer_columns: Vec::new(),
736        };
737        return Ok((columns, Vec::new()));
738    };
739    let header = parse_one(text, 0..header_end, path, 1).map_err(table_error)?;
740    let column_positions = RunsColumnPositions::find(&header, path).map_err(table_error)?;
741    let mut records = Vec::with_capacity(record_ends.len());
742    let mut start = header_end;
743    for (index, &end) in record_ends.iter().enumerate() {
744        let record_number = index + 2;
745        let fields = parse_one(text, start..end, path, record_number).map_err(table_error)?;
746        if fields.len() != header.len() {
747            return Err(table_error(ReadError::FieldCount {
748                path: path.to_owned(),
749                record_number,
750                found: fields.len(),
751                expected: header.len(),
752            }));
753        }
754        records.push(
755            column_positions
756                .record(&header, &fields, width, path, record_number)
757                .map_err(table_error)?,
758        );
759        start = end;
760    }
761    let columns = RunsColumnNames {
762        value_columns: header[column_positions.values.clone()].to_vec(),
763        reducer_columns: header[column_positions.reducers.clone()].to_vec(),
764    };
765    Ok((columns, records))
766}
767
768/// Positions of the columns of a `runs.csv` header.
769struct RunsColumnPositions {
770    /// Parameter and action columns, the ones between `run_key` and `status`.
771    values: std::ops::Range<usize>,
772    /// Position of `status`, the first of the outcome columns.
773    status: usize,
774    /// Reducer columns, the ones between `steps_per_s` and `note`.
775    reducers: std::ops::Range<usize>,
776}
777
778impl RunsColumnPositions {
779    /// Finds the columns in `header`. The ids come first in their own order, and `status` is found from the end,
780    /// since a parameter can also be named `status`.
781    fn find(header: &[String], path: &Path) -> Result<Self, ReadError> {
782        let missing = |column: &'static str| ReadError::MissingColumn {
783            path: path.to_owned(),
784            column,
785        };
786        for (position, &column) in ID_COLUMNS.iter().enumerate() {
787            if header.get(position).is_none_or(|name| name != column) {
788                return Err(missing(column));
789            }
790        }
791        let status = header
792            .iter()
793            .rposition(|name| name == OUTCOME_COLUMNS[0])
794            .ok_or_else(|| missing(OUTCOME_COLUMNS[0]))?;
795        for (offset, &column) in OUTCOME_COLUMNS.iter().enumerate() {
796            if header.get(status + offset).is_none_or(|name| name != column) {
797                return Err(missing(column));
798            }
799        }
800        if header.last().is_none_or(|name| name != NOTE_COLUMN) {
801            return Err(missing(NOTE_COLUMN));
802        }
803        Ok(Self {
804            values: ID_COLUMNS.len()..status,
805            status,
806            reducers: status + OUTCOME_COLUMNS.len()..header.len() - 1,
807        })
808    }
809
810    /// Returns the run of `fields`, record `record_number` of the file at `path`, with an empty series `width` wide.
811    fn record(
812        &self,
813        header: &[String],
814        fields: &[String],
815        width: usize,
816        path: &Path,
817        record_number: usize,
818    ) -> Result<RunRow, ReadError> {
819        let bad_field = |column: usize| ReadError::BadField {
820            path: path.to_owned(),
821            record_number,
822            column: header[column].clone(),
823            text: fields[column].clone(),
824        };
825        let integer = |column: usize| fields[column].parse::<u64>().ok().ok_or_else(|| bad_field(column));
826        let real = |column: usize| match fields[column].as_str() {
827            "" => Ok(f64::NAN),
828            text => text.parse::<f64>().ok().ok_or_else(|| bad_field(column)),
829        };
830        let status = self.status;
831        let reducers = self
832            .reducers
833            .clone()
834            .map(|column| match fields[column].as_str() {
835                "" => Ok(None),
836                text => text.parse::<f64>().map(Some).ok().ok_or_else(|| bad_field(column)),
837            })
838            .collect::<Result<Vec<_>, _>>()?;
839        let note = &fields[fields.len() - 1];
840        let outcome = RunOutcome {
841            run: PlannedRun {
842                run_id: integer(0)?,
843                config_id: integer(1)?,
844                rep: integer(3)?,
845                seed: integer(4)?,
846            },
847            run_key: u64::from_str_radix(&fields[5], 16).ok().ok_or_else(|| bad_field(5))?,
848            status: fields[status].parse().ok().ok_or_else(|| bad_field(status))?,
849            stop_reason: fields[status + 1].parse().ok().ok_or_else(|| bad_field(status + 1))?,
850            ticks: integer(status + 2)?,
851            population: integer(status + 3)?,
852            build_ms: real(status + 4)?,
853            wall_ms: real(status + 5)?,
854            reducers,
855            series: SeriesBuffer::new(width),
856            note: (!note.is_empty()).then(|| note.clone()),
857        };
858        Ok(RunRow {
859            outcome,
860            block: fields[2].parse().ok().ok_or_else(|| bad_field(2))?,
861            values: fields[self.values.clone()].to_vec(),
862            series_held: true,
863        })
864    }
865}
866
867/// Results that cannot be read.
868#[derive(Debug)]
869pub enum ResultSetError {
870    /// The directory or the files passed in lack `file`.
871    Missing {
872        /// Name of the missing file, `manifest.json`, `runs.csv` or `series.csv`.
873        file: &'static str,
874    },
875    /// Two of the files passed in hold `file`.
876    Duplicate {
877        /// Name of the result file that both files hold, such as `manifest.json` or `runs.csv`.
878        file: &'static str,
879    },
880    /// The manifest cannot be read.
881    Manifest(ManifestError),
882    /// The manifest's spec cannot be read back.
883    Spec(SpecFileError),
884    /// The search the manifest records has a setting that [`SearchSpec::check`] rejects.
885    ///
886    /// [`SearchSpec::check`]: henad_core::explore::search::SearchSpec::check
887    Search(SearchSpecError),
888    /// A table cannot be read.
889    Table(ReadError),
890}
891
892impl fmt::Display for ResultSetError {
893    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
894        match self {
895            Self::Missing { file } => write!(f, "the results are missing {file}"),
896            Self::Duplicate { file } => write!(f, "two selected files hold {file}"),
897            Self::Manifest(_) => f.write_str("cannot read the manifest"),
898            Self::Spec(_) => f.write_str("cannot read the spec the manifest records"),
899            Self::Search(_) => f.write_str("the search the manifest records has an invalid setting"),
900            Self::Table(_) => f.write_str("cannot read the results"),
901        }
902    }
903}
904
905impl std::error::Error for ResultSetError {
906    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
907        match self {
908            Self::Missing { .. } | Self::Duplicate { .. } => None,
909            Self::Manifest(error) => Some(error),
910            Self::Spec(error) => Some(error),
911            Self::Search(error) => Some(error),
912            Self::Table(error) => Some(error),
913        }
914    }
915}
916
917/// A run of a result set that cannot be replayed.
918#[derive(Debug)]
919pub enum ResultReplayError {
920    /// The model rejects the recorded spec.
921    Plan(PlanError),
922    /// The model rejects the recorded search.
923    Search(SearchPlanError),
924    /// Run `run_id`, absent from `runs.csv`.
925    UnknownRun {
926        /// Id of the requested run.
927        run_id: u64,
928    },
929    /// Run `run_id`, whose config, replicate, seed or run key differs between its row and the plan.
930    Mismatch {
931        /// Id of the requested run.
932        run_id: u64,
933    },
934}
935
936impl fmt::Display for ResultReplayError {
937    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
938        match self {
939            Self::Plan(_) | Self::Search(_) => f.write_str("the model refuses the spec of these results"),
940            Self::UnknownRun { run_id } => write!(f, "the results hold no run {run_id}"),
941            Self::Mismatch { run_id } => write!(f, "run {run_id} of the results does not match its plan"),
942        }
943    }
944}
945
946impl std::error::Error for ResultReplayError {
947    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
948        match self {
949            Self::Plan(error) => Some(error),
950            Self::Search(error) => Some(error),
951            Self::UnknownRun { .. } | Self::Mismatch { .. } => None,
952        }
953    }
954}
955
956#[cfg(test)]
957mod tests {
958    use henad_core::explore::factor::{FactorSpec, LevelSpec};
959    use henad_core::explore::plan::ModelSchema;
960    use henad_core::explore::replay::Replay;
961    use henad_core::explore::search::{Aggregate, Goal, Objective, SearchAlgorithm, SearchSpec};
962    use henad_core::explore::spec::SweepSpec;
963    use henad_core::params::{ParamDescriptor, ParamValue};
964
965    use super::ResultSet;
966    use crate::progress::NoProgress;
967    use crate::tests::support::{ScratchDir, entry, sweep_options, sweep_with};
968
969    /// Returns the value that `replay` assigns to parameter `id`, whose schema lists `params`.
970    fn value_of(replay: &Replay, params: &[ParamDescriptor], id: &str) -> ParamValue {
971        let position = params
972            .iter()
973            .position(|param| param.id == id)
974            .expect("the schema has the parameter");
975        replay.params[position].clone()
976    }
977
978    #[test]
979    fn a_search_run_replays_each_value_under_its_parameter_id() {
980        let sir = entry("sir", None);
981        let mut spec = SweepSpec::new("sir");
982        spec.fixed = vec![
983            ("grid_width".to_owned(), "8".to_owned()),
984            ("grid_height".to_owned(), "8".to_owned()),
985        ];
986        spec.run.steps = 4;
987        spec.measure.default_reducers = false;
988        spec.measure.reducers = vec!["Infected:max".parse().expect("a valid reducer")];
989        let rate = |min, max| LevelSpec::Range { min, max, step: None };
990        spec.search = Some(SearchSpec {
991            algorithm: SearchAlgorithm::Random,
992            max_evaluations: 2,
993            batch_size: 2,
994            objective: Some(Objective {
995                column: "Infected:max".to_owned(),
996                goal: Goal::Minimize,
997                aggregate: Aggregate::Median,
998            }),
999            space: vec![
1000                FactorSpec::param("infection_rate", rate(0.5, 0.9)),
1001                FactorSpec::param("recovery_rate", rate(0.02, 0.3)),
1002            ],
1003        });
1004        let scratch = ScratchDir::new("search-replay-by-id");
1005        sweep_with(
1006            &sir,
1007            None,
1008            &spec,
1009            scratch.path(),
1010            &sweep_options(false),
1011            &mut NoProgress,
1012        )
1013        .expect("the search runs");
1014        let set = ResultSet::open_dir(scratch.path(), usize::MAX).expect("the folder reads back");
1015        let schema = sir.schema();
1016        let replay = set.replay(schema, 0).expect("the run replays");
1017
1018        // A later version of the model lists its two rates the other way round.
1019        let mut params = schema.params.to_vec();
1020        let position = |id: &str| params.iter().position(|param| param.id == id).expect("a SIR parameter");
1021        let (infection, recovery) = (position("infection_rate"), position("recovery_rate"));
1022        params.swap(infection, recovery);
1023        let reordered = ModelSchema {
1024            params: &params,
1025            ..schema
1026        };
1027        let swapped = set.replay(reordered, 0).expect("the run replays");
1028        for id in ["infection_rate", "recovery_rate", "grid_width"] {
1029            assert_eq!(
1030                value_of(&swapped, &params, id),
1031                value_of(&replay, schema.params, id),
1032                "{id}"
1033            );
1034        }
1035    }
1036}