1use 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#[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#[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#[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
212pub 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}