Skip to main content

ta_benchmarks/
catalogue_evidence.rs

1//! Raw evidence and publication policy for the Indicator Catalogue matrix.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5use std::fs;
6use std::path::Path;
7
8use crate::catalogue_cases::MATRIX;
9use crate::catalogue_statistics::validate_positive_timing_evidence;
10pub use crate::catalogue_statistics::{timing_stats, TimingStats};
11
12pub const INPUT_LENGTHS: [usize; 3] = [256, 4_096, 65_536];
13pub const FIXTURE_ID: &str = "catalogue_fixture_v1:f64le";
14pub const RUST_OWNED_MODE: &str = "Owned Compact Output";
15pub const RUST_CALLER_MODE: &str = "caller-owned Batch Computation";
16pub const RUST_PREPARED_MODE: &str = "Prepared Batch Runner";
17pub const RUST_STREAMING_MODE: &str = "Streaming Computation";
18pub const C_DIRECT_MODE: &str = "direct C caller-owned";
19pub const PRIMARY_COMPARISON: &str = "primary caller-owned Rust/C kernel";
20pub const PYTHON_MODE: &str = "official Python NumPy API";
21pub const RAW_HEADER: &str = "implementation\tindicator_family\tindicator_definition\tcase_id\tmode\tparameters\tinput_length\toutput_kind\toutput_arity\tmedian_ns\tci95_lower_ns\tci95_upper_ns\tthroughput_observations_per_second\tsample_count\toutlier_count\toutlier_low_count\toutlier_high_count\toutput_begin\toutput_count\toutput_checksum\tsemantic_status\tsemantic_reason\ttiming_status\ttiming_reason\tcomparison_status\tcomparison_reason\twarmup_iterations\titerations_per_sample\ttimed_boundary\tfixture\tinput_checksum\tta_lib_version\tta_lib_revision\tpython_version\tpython_binding_version\tpython_ta_lib_version\tnumpy_version\trustc\tcpu\tos\tarch\tfloat_width\tfeatures\tcommit\tdirty";
22
23pub const PUBLICATION_VARIANTS: [(&str, &str); 6] = [
24    ("fast-ta", RUST_OWNED_MODE),
25    ("fast-ta", RUST_CALLER_MODE),
26    ("fast-ta", RUST_PREPARED_MODE),
27    ("fast-ta", RUST_STREAMING_MODE),
28    ("TA-Lib C", C_DIRECT_MODE),
29    ("TA-Lib Python", PYTHON_MODE),
30];
31pub const CANONICAL_INPUT_CHECKSUMS: [(usize, &str); 3] = [
32    (256, "fnv1a64:73fedfe0ae0a803f"),
33    (4_096, "fnv1a64:06be03be64d63c6c"),
34    (65_536, "fnv1a64:a4171d0a7611733a"),
35];
36
37/// Canonical requirements for evidence that may replace the published matrix.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub struct CataloguePublicationPolicy {
40    pub input_lengths: &'static [usize],
41    pub variants: &'static [(&'static str, &'static str)],
42    pub input_checksums: &'static [(usize, &'static str)],
43    pub semantic_status: &'static str,
44    pub timing_status: &'static str,
45    pub sample_count: usize,
46    pub fixture: &'static str,
47    pub ta_lib_version: &'static str,
48    pub ta_lib_revision: &'static str,
49    pub python_binding_version: &'static str,
50    pub python_ta_lib_version: &'static str,
51    pub numpy_version: &'static str,
52    pub float_width: usize,
53    pub features: &'static str,
54}
55
56pub const PUBLICATION_POLICY: CataloguePublicationPolicy = CataloguePublicationPolicy {
57    input_lengths: &INPUT_LENGTHS,
58    variants: &PUBLICATION_VARIANTS,
59    input_checksums: &CANONICAL_INPUT_CHECKSUMS,
60    semantic_status: "verified",
61    timing_status: "measured",
62    sample_count: 50,
63    fixture: FIXTURE_ID,
64    ta_lib_version: "0.6.4",
65    ta_lib_revision: "43f9d5042ecc4bd367941846494ad907bf20ea50",
66    python_binding_version: "0.6.4",
67    python_ta_lib_version: "0.6.4",
68    numpy_version: "2.2.3",
69    float_width: 64,
70    features: "fast-ta=default(f64,std); ta-benchmarks=catalogue-matrix",
71};
72const LEGACY_REPORT_FEATURES: &str = "ta-core=default(f64,std); ta-benchmarks=catalogue-matrix";
73
74#[derive(Clone, Debug, PartialEq)]
75pub struct BenchmarkRow {
76    pub implementation: String,
77    pub indicator_family: String,
78    pub indicator_definition: String,
79    pub case_id: String,
80    pub mode: String,
81    pub parameters: String,
82    pub input_length: usize,
83    pub output_kind: String,
84    pub output_arity: Option<usize>,
85    pub stats: Option<TimingStats>,
86    pub output_begin: Option<usize>,
87    pub output_count: Option<usize>,
88    pub output_checksum: String,
89    pub semantic_status: String,
90    pub semantic_reason: String,
91    pub timing_status: String,
92    pub timing_reason: String,
93    pub comparison_status: String,
94    pub comparison_reason: String,
95    pub warmup_iterations: Option<u64>,
96    pub iterations_per_sample: Option<u64>,
97    pub timed_boundary: String,
98    pub fixture: String,
99    pub input_checksum: String,
100    pub ta_lib_version: String,
101    pub ta_lib_revision: String,
102    pub python_version: String,
103    pub python_binding_version: String,
104    pub python_ta_lib_version: String,
105    pub numpy_version: String,
106    pub rustc: String,
107    pub cpu: String,
108    pub os: String,
109    pub arch: String,
110    pub float_width: usize,
111    pub features: String,
112    pub commit: String,
113    pub dirty: bool,
114}
115
116pub fn write_raw_rows(path: &Path, rows: &[BenchmarkRow]) -> Result<(), String> {
117    let mut output = String::from(RAW_HEADER);
118    output.push('\n');
119    for (index, row) in rows.iter().enumerate() {
120        validate_benchmark_row_evidence(row)
121            .map_err(|error| format!("raw row {}: {error}", index + 2))?;
122        output.push_str(&format_row(row));
123        output.push('\n');
124    }
125    fs::write(path, output).map_err(|error| format!("write {}: {error}", path.display()))
126}
127
128pub fn read_raw_rows(path: &Path) -> Result<Vec<BenchmarkRow>, String> {
129    let input =
130        fs::read_to_string(path).map_err(|error| format!("read {}: {error}", path.display()))?;
131    parse_raw_rows(&input)
132}
133
134pub fn parse_raw_rows(input: &str) -> Result<Vec<BenchmarkRow>, String> {
135    let mut lines = input.lines();
136    if lines.next() != Some(RAW_HEADER) {
137        return Err("unexpected Indicator Catalogue matrix raw-row header".to_owned());
138    }
139    lines
140        .enumerate()
141        .filter(|(_, line)| !line.is_empty())
142        .map(|(index, line)| {
143            parse_row(line).map_err(|error| format!("raw row {}: {error}", index + 2))
144        })
145        .collect()
146}
147
148/// Uniform provenance returned only after the complete publication policy passes.
149#[derive(Clone, Debug, PartialEq, Eq)]
150pub struct PublicationProvenance {
151    pub python_version: String,
152    pub rustc: String,
153    pub cpu: String,
154    pub os: String,
155    pub arch: String,
156    pub commit: String,
157}
158
159/// Parsed rows whose completeness, timings, provenance, and publication policy are validated.
160#[derive(Clone, Debug, PartialEq)]
161pub struct ValidatedCatalogueEvidence {
162    rows: Vec<BenchmarkRow>,
163    provenance: PublicationProvenance,
164}
165
166impl ValidatedCatalogueEvidence {
167    pub fn rows(&self) -> &[BenchmarkRow] {
168        &self.rows
169    }
170
171    pub fn provenance(&self) -> &PublicationProvenance {
172        &self.provenance
173    }
174
175    pub fn into_rows(self) -> Vec<BenchmarkRow> {
176        self.rows
177    }
178}
179
180#[derive(Clone, Debug, PartialEq, Eq)]
181pub struct CatalogueEvidenceError {
182    message: String,
183}
184
185impl CatalogueEvidenceError {
186    fn new(message: impl Into<String>) -> Self {
187        Self {
188            message: message.into(),
189        }
190    }
191
192    pub fn message(&self) -> &str {
193        &self.message
194    }
195}
196
197impl fmt::Display for CatalogueEvidenceError {
198    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
199        formatter.write_str(&self.message)
200    }
201}
202
203impl std::error::Error for CatalogueEvidenceError {}
204
205pub fn read_publishable_evidence(
206    path: &Path,
207) -> Result<ValidatedCatalogueEvidence, CatalogueEvidenceError> {
208    let rows = read_raw_rows(path).map_err(CatalogueEvidenceError::new)?;
209    validate_publishable_rows(rows)
210}
211
212/// Reads evidence for report regeneration, including immutable legacy baselines.
213///
214/// Legacy crate provenance is accepted only through this report-specific seam;
215/// publication validation requires the current `fast-ta` provenance.
216pub fn read_report_evidence(
217    path: &Path,
218) -> Result<ValidatedCatalogueEvidence, CatalogueEvidenceError> {
219    let rows = read_raw_rows(path).map_err(CatalogueEvidenceError::new)?;
220    let expected_features = if rows
221        .iter()
222        .all(|row| row.features == LEGACY_REPORT_FEATURES)
223    {
224        LEGACY_REPORT_FEATURES
225    } else {
226        PUBLICATION_POLICY.features
227    };
228    validate_rows_with_features(rows, expected_features)
229}
230
231pub fn validate_publishable_rows(
232    rows: Vec<BenchmarkRow>,
233) -> Result<ValidatedCatalogueEvidence, CatalogueEvidenceError> {
234    validate_rows_with_features(rows, PUBLICATION_POLICY.features)
235}
236
237fn validate_rows_with_features(
238    rows: Vec<BenchmarkRow>,
239    expected_features: &str,
240) -> Result<ValidatedCatalogueEvidence, CatalogueEvidenceError> {
241    validate_complete_matrix(&rows)?;
242    for (index, row) in rows.iter().enumerate() {
243        validate_benchmark_row_evidence(row).map_err(|error| {
244            CatalogueEvidenceError::new(format!("cannot publish raw row {}: {error}", index + 2))
245        })?;
246    }
247    validate_fixed_publication_fields(&rows, expected_features)?;
248    let provenance = validate_uniform_provenance(&rows)?;
249    validate_input_checksums(&rows)?;
250    validate_case_provenance(&rows)?;
251    Ok(ValidatedCatalogueEvidence { rows, provenance })
252}
253
254fn validate_complete_matrix(rows: &[BenchmarkRow]) -> Result<(), CatalogueEvidenceError> {
255    type Cell = (String, usize, String, String);
256    let mut expected = BTreeMap::<Cell, usize>::new();
257    for case in MATRIX {
258        for &input_length in PUBLICATION_POLICY.input_lengths {
259            for &(implementation, mode) in PUBLICATION_POLICY.variants {
260                *expected
261                    .entry((
262                        case.id.to_owned(),
263                        input_length,
264                        implementation.to_owned(),
265                        mode.to_owned(),
266                    ))
267                    .or_default() += 1;
268            }
269        }
270    }
271    let mut actual = BTreeMap::<Cell, usize>::new();
272    for row in rows {
273        *actual
274            .entry((
275                row.case_id.clone(),
276                row.input_length,
277                row.implementation.clone(),
278                row.mode.clone(),
279            ))
280            .or_default() += 1;
281    }
282    if actual == expected {
283        return Ok(());
284    }
285
286    let mut missing = Vec::new();
287    let mut unexpected = Vec::new();
288    for (cell, count) in &expected {
289        let difference = count.saturating_sub(actual.get(cell).copied().unwrap_or(0));
290        missing.extend(std::iter::repeat_n(cell, difference));
291    }
292    for (cell, count) in &actual {
293        let difference = count.saturating_sub(expected.get(cell).copied().unwrap_or(0));
294        unexpected.extend(std::iter::repeat_n(cell, difference));
295    }
296    Err(CatalogueEvidenceError::new(format!(
297        "cannot publish incomplete case/input/mode matrix: missing={:?}; unexpected_or_duplicate={:?}",
298        &missing[..missing.len().min(5)],
299        &unexpected[..unexpected.len().min(5)]
300    )))
301}
302
303fn validate_fixed_publication_fields(
304    rows: &[BenchmarkRow],
305    expected_features: &str,
306) -> Result<(), CatalogueEvidenceError> {
307    require_fixed(
308        rows,
309        "semantic_status",
310        PUBLICATION_POLICY.semantic_status,
311        |row| row.semantic_status == PUBLICATION_POLICY.semantic_status,
312    )?;
313    require_fixed(
314        rows,
315        "timing_status",
316        PUBLICATION_POLICY.timing_status,
317        |row| row.timing_status == PUBLICATION_POLICY.timing_status,
318    )?;
319    require_fixed(rows, "sample_count", "50", |row| {
320        row.stats
321            .as_ref()
322            .is_some_and(|stats| stats.sample_count == PUBLICATION_POLICY.sample_count)
323    })?;
324    require_fixed(rows, "dirty", "false", |row| !row.dirty)?;
325    require_fixed(rows, "fixture", PUBLICATION_POLICY.fixture, |row| {
326        row.fixture == PUBLICATION_POLICY.fixture
327    })?;
328    require_fixed(
329        rows,
330        "ta_lib_version",
331        PUBLICATION_POLICY.ta_lib_version,
332        |row| row.ta_lib_version == PUBLICATION_POLICY.ta_lib_version,
333    )?;
334    require_fixed(
335        rows,
336        "ta_lib_revision",
337        PUBLICATION_POLICY.ta_lib_revision,
338        |row| row.ta_lib_revision == PUBLICATION_POLICY.ta_lib_revision,
339    )?;
340    require_fixed(
341        rows,
342        "python_binding_version",
343        PUBLICATION_POLICY.python_binding_version,
344        |row| row.python_binding_version == PUBLICATION_POLICY.python_binding_version,
345    )?;
346    require_fixed(
347        rows,
348        "python_ta_lib_version",
349        PUBLICATION_POLICY.python_ta_lib_version,
350        |row| row.python_ta_lib_version == PUBLICATION_POLICY.python_ta_lib_version,
351    )?;
352    require_fixed(
353        rows,
354        "numpy_version",
355        PUBLICATION_POLICY.numpy_version,
356        |row| row.numpy_version == PUBLICATION_POLICY.numpy_version,
357    )?;
358    require_fixed(rows, "float_width", "64", |row| {
359        row.float_width == PUBLICATION_POLICY.float_width
360    })?;
361    require_fixed(rows, "features", expected_features, |row| {
362        row.features == expected_features
363    })
364}
365
366fn require_fixed(
367    rows: &[BenchmarkRow],
368    field: &str,
369    expected: &str,
370    predicate: impl Fn(&BenchmarkRow) -> bool,
371) -> Result<(), CatalogueEvidenceError> {
372    let invalid = rows.iter().filter(|row| !predicate(row)).count();
373    if invalid != 0 {
374        return Err(CatalogueEvidenceError::new(format!(
375            "cannot publish: {invalid} rows have {field} other than {expected:?}"
376        )));
377    }
378    Ok(())
379}
380
381fn validate_uniform_provenance(
382    rows: &[BenchmarkRow],
383) -> Result<PublicationProvenance, CatalogueEvidenceError> {
384    fn uniform(
385        rows: &[BenchmarkRow],
386        field: &str,
387        value: impl Fn(&BenchmarkRow) -> &str,
388    ) -> Result<String, CatalogueEvidenceError> {
389        let values = rows.iter().map(value).collect::<BTreeSet<_>>();
390        if values.len() != 1
391            || values.first().is_none_or(|value| value.is_empty())
392            || values.contains("unavailable")
393        {
394            return Err(CatalogueEvidenceError::new(format!(
395                "cannot publish inconsistent {field} provenance: {values:?}"
396            )));
397        }
398        Ok(values
399            .into_iter()
400            .next()
401            .expect("one uniform value")
402            .to_owned())
403    }
404
405    Ok(PublicationProvenance {
406        python_version: uniform(rows, "python_version", |row| &row.python_version)?,
407        rustc: uniform(rows, "rustc", |row| &row.rustc)?,
408        cpu: uniform(rows, "cpu", |row| &row.cpu)?,
409        os: uniform(rows, "os", |row| &row.os)?,
410        arch: uniform(rows, "arch", |row| &row.arch)?,
411        commit: uniform(rows, "commit", |row| &row.commit)?,
412    })
413}
414
415fn validate_input_checksums(rows: &[BenchmarkRow]) -> Result<(), CatalogueEvidenceError> {
416    for &(input_length, expected_checksum) in PUBLICATION_POLICY.input_checksums {
417        let invalid = rows
418            .iter()
419            .filter(|row| {
420                row.input_length == input_length && row.input_checksum != expected_checksum
421            })
422            .count();
423        if invalid != 0 {
424            return Err(CatalogueEvidenceError::new(format!(
425                "cannot publish: {invalid} rows have noncanonical input checksum provenance for {input_length}"
426            )));
427        }
428    }
429    Ok(())
430}
431
432fn validate_case_provenance(rows: &[BenchmarkRow]) -> Result<(), CatalogueEvidenceError> {
433    for case in MATRIX {
434        let definitions = rows
435            .iter()
436            .filter(|row| row.case_id == case.id)
437            .map(|row| {
438                (
439                    row.indicator_family.as_str(),
440                    row.indicator_definition.as_str(),
441                    row.parameters.as_str(),
442                    row.output_kind.as_str(),
443                    row.output_arity,
444                )
445            })
446            .collect::<BTreeSet<_>>();
447        let expected = BTreeSet::from([(
448            case.family,
449            case.definition,
450            case.parameters,
451            case.output_kind,
452            Some(case.output_arity),
453        )]);
454        if definitions != expected {
455            return Err(CatalogueEvidenceError::new(format!(
456                "cannot publish case provenance that differs from catalogue-cases.tsv for {}: expected={expected:?}; actual={definitions:?}",
457                case.id
458            )));
459        }
460    }
461    Ok(())
462}
463
464fn format_row(row: &BenchmarkRow) -> String {
465    let stats = row.stats.as_ref();
466    [
467        clean(&row.implementation),
468        clean(&row.indicator_family),
469        clean(&row.indicator_definition),
470        clean(&row.case_id),
471        clean(&row.mode),
472        clean(&row.parameters),
473        row.input_length.to_string(),
474        clean(&row.output_kind),
475        opt(row.output_arity),
476        opt_float(stats.map(|value| value.median_ns)),
477        opt_float(stats.map(|value| value.ci95_lower_ns)),
478        opt_float(stats.map(|value| value.ci95_upper_ns)),
479        opt_float(stats.map(|value| value.throughput_observations_per_second)),
480        opt(stats.map(|value| value.sample_count)),
481        opt(stats.map(|value| value.outlier_count)),
482        opt(stats.map(|value| value.outlier_low_count)),
483        opt(stats.map(|value| value.outlier_high_count)),
484        opt(row.output_begin),
485        opt(row.output_count),
486        clean(&row.output_checksum),
487        clean(&row.semantic_status),
488        clean(&row.semantic_reason),
489        clean(&row.timing_status),
490        clean(&row.timing_reason),
491        clean(&row.comparison_status),
492        clean(&row.comparison_reason),
493        opt(row.warmup_iterations),
494        opt(row.iterations_per_sample),
495        clean(&row.timed_boundary),
496        clean(&row.fixture),
497        clean(&row.input_checksum),
498        clean(&row.ta_lib_version),
499        clean(&row.ta_lib_revision),
500        clean(&row.python_version),
501        clean(&row.python_binding_version),
502        clean(&row.python_ta_lib_version),
503        clean(&row.numpy_version),
504        clean(&row.rustc),
505        clean(&row.cpu),
506        clean(&row.os),
507        clean(&row.arch),
508        row.float_width.to_string(),
509        clean(&row.features),
510        clean(&row.commit),
511        row.dirty.to_string(),
512    ]
513    .join("\t")
514}
515
516fn parse_row(line: &str) -> Result<BenchmarkRow, String> {
517    let fields = line.split('\t').collect::<Vec<_>>();
518    if fields.len() != 45 {
519        return Err(format!("expected 45 columns, got {}", fields.len()));
520    }
521    let parsed_stats = match fields[9] {
522        "NA" => None,
523        _ => Some(TimingStats {
524            median_ns: parse(fields[9], "median_ns")?,
525            ci95_lower_ns: parse(fields[10], "ci95_lower_ns")?,
526            ci95_upper_ns: parse(fields[11], "ci95_upper_ns")?,
527            throughput_observations_per_second: parse(fields[12], "throughput")?,
528            sample_count: parse(fields[13], "sample_count")?,
529            outlier_count: parse(fields[14], "outlier_count")?,
530            outlier_low_count: parse(fields[15], "outlier_low_count")?,
531            outlier_high_count: parse(fields[16], "outlier_high_count")?,
532        }),
533    };
534    let row = BenchmarkRow {
535        implementation: fields[0].to_owned(),
536        indicator_family: fields[1].to_owned(),
537        indicator_definition: fields[2].to_owned(),
538        case_id: fields[3].to_owned(),
539        mode: fields[4].to_owned(),
540        parameters: fields[5].to_owned(),
541        input_length: parse(fields[6], "input_length")?,
542        output_kind: fields[7].to_owned(),
543        output_arity: parse_opt(fields[8], "output_arity")?,
544        stats: parsed_stats,
545        output_begin: parse_opt(fields[17], "output_begin")?,
546        output_count: parse_opt(fields[18], "output_count")?,
547        output_checksum: fields[19].to_owned(),
548        semantic_status: fields[20].to_owned(),
549        semantic_reason: fields[21].to_owned(),
550        timing_status: fields[22].to_owned(),
551        timing_reason: fields[23].to_owned(),
552        comparison_status: fields[24].to_owned(),
553        comparison_reason: fields[25].to_owned(),
554        warmup_iterations: parse_opt(fields[26], "warmup_iterations")?,
555        iterations_per_sample: parse_opt(fields[27], "iterations_per_sample")?,
556        timed_boundary: fields[28].to_owned(),
557        fixture: fields[29].to_owned(),
558        input_checksum: fields[30].to_owned(),
559        ta_lib_version: fields[31].to_owned(),
560        ta_lib_revision: fields[32].to_owned(),
561        python_version: fields[33].to_owned(),
562        python_binding_version: fields[34].to_owned(),
563        python_ta_lib_version: fields[35].to_owned(),
564        numpy_version: fields[36].to_owned(),
565        rustc: fields[37].to_owned(),
566        cpu: fields[38].to_owned(),
567        os: fields[39].to_owned(),
568        arch: fields[40].to_owned(),
569        float_width: parse(fields[41], "float_width")?,
570        features: fields[42].to_owned(),
571        commit: fields[43].to_owned(),
572        dirty: parse(fields[44], "dirty")?,
573    };
574    if row.stats.is_none() && fields[10..17].iter().any(|value| *value != "NA") {
575        return Err("partial unavailable timing statistics".to_owned());
576    }
577    validate_benchmark_row_evidence(&row)?;
578    Ok(row)
579}
580
581fn validate_benchmark_row_evidence(row: &BenchmarkRow) -> Result<(), String> {
582    if row.input_length == 0 {
583        return Err("input_length must be positive".to_owned());
584    }
585    for (name, iterations) in [
586        ("warmup_iterations", row.warmup_iterations),
587        ("iterations_per_sample", row.iterations_per_sample),
588    ] {
589        if iterations == Some(0) {
590            return Err(format!("{name} must be positive when present"));
591        }
592    }
593    let Some(stats) = &row.stats else {
594        return Ok(());
595    };
596    if row.warmup_iterations.is_none() || row.iterations_per_sample.is_none() {
597        return Err(
598            "measured timing evidence requires warmup_iterations and iterations_per_sample"
599                .to_owned(),
600        );
601    }
602    validate_positive_timing_evidence(
603        stats.median_ns,
604        stats.ci95_lower_ns,
605        stats.ci95_upper_ns,
606        stats.throughput_observations_per_second,
607        stats.sample_count,
608        row.input_length,
609    )?;
610    let classified_outliers = stats
611        .outlier_low_count
612        .checked_add(stats.outlier_high_count)
613        .ok_or_else(|| "outlier counts overflow".to_owned())?;
614    if stats.outlier_count != classified_outliers || stats.outlier_count > stats.sample_count {
615        return Err("outlier counts are incoherent with sample_count".to_owned());
616    }
617    Ok(())
618}
619
620fn parse<T>(value: &str, name: &str) -> Result<T, String>
621where
622    T: std::str::FromStr,
623    T::Err: fmt::Display,
624{
625    value
626        .parse()
627        .map_err(|error| format!("invalid {name} {value:?}: {error}"))
628}
629
630fn parse_opt<T>(value: &str, name: &str) -> Result<Option<T>, String>
631where
632    T: std::str::FromStr,
633    T::Err: fmt::Display,
634{
635    if value == "NA" {
636        Ok(None)
637    } else {
638        parse(value, name).map(Some)
639    }
640}
641
642fn opt<T: ToString>(value: Option<T>) -> String {
643    value.map_or_else(|| "NA".to_owned(), |value| value.to_string())
644}
645
646fn opt_float(value: Option<f64>) -> String {
647    value.map_or_else(|| "NA".to_owned(), |value| value.to_string())
648}
649
650pub(crate) fn clean(value: &str) -> String {
651    value.replace(['\t', '\r', '\n', '|'], " ")
652}