1use std::fmt;
7use std::str::FromStr;
8
9use crate::explore::stop::{Comparison, ComparisonError};
10use crate::export::StatColumns;
11use crate::view::StatDescriptor;
12
13#[derive(Debug, Clone, Copy, PartialEq)]
15pub enum ReducerKind {
16 Final,
18 Min,
20 Max,
22 Mean,
24 ArgMax,
26 ArgMin,
28 FirstCrossing(Comparison),
30 WindowMean {
32 start: u64,
34 end: u64,
36 },
37}
38
39impl ReducerKind {
40 pub const DEFAULTS: [Self; 4] = [Self::Final, Self::Min, Self::Max, Self::Mean];
42}
43
44impl fmt::Display for ReducerKind {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 match self {
47 Self::Final => f.write_str("final"),
48 Self::Min => f.write_str("min"),
49 Self::Max => f.write_str("max"),
50 Self::Mean => f.write_str("mean"),
51 Self::ArgMax => f.write_str("argmax"),
52 Self::ArgMin => f.write_str("argmin"),
53 Self::FirstCrossing(comparison) => write!(f, "first{comparison}"),
54 Self::WindowMean { start, end } => write!(f, "mean@{start}..{end}"),
55 }
56 }
57}
58
59impl FromStr for ReducerKind {
60 type Err = ReducerError;
61
62 fn from_str(raw: &str) -> Result<Self, Self::Err> {
64 match raw {
65 "final" => return Ok(Self::Final),
66 "min" => return Ok(Self::Min),
67 "max" => return Ok(Self::Max),
68 "mean" => return Ok(Self::Mean),
69 "argmax" => return Ok(Self::ArgMax),
70 "argmin" => return Ok(Self::ArgMin),
71 _ => {}
72 }
73 if let Some(comparison) = raw.strip_prefix("first") {
74 return comparison
75 .parse()
76 .map(Self::FirstCrossing)
77 .map_err(|source| ReducerError::Comparison {
78 raw: raw.to_owned(),
79 source,
80 });
81 }
82 if let Some(window) = raw.strip_prefix("mean@") {
83 let tick = |text: &str| text.parse::<u64>().ok();
84 return window
85 .split_once("..")
86 .and_then(|(start, end)| Some((tick(start)?, tick(end)?)))
87 .filter(|(start, end)| start <= end)
88 .map(|(start, end)| Self::WindowMean { start, end })
89 .ok_or_else(|| ReducerError::BadWindow { raw: raw.to_owned() });
90 }
91 Err(ReducerError::UnknownKind { raw: raw.to_owned() })
92 }
93}
94
95pub(crate) fn names_a_stat(column: &str, stats: &[StatDescriptor]) -> bool {
97 stats.iter().any(|stat| {
98 column
99 .strip_prefix(stat.label)
100 .is_some_and(|rest| rest.is_empty() || rest.starts_with('.'))
101 })
102}
103
104#[derive(Debug, Clone, PartialEq)]
106pub struct ReducerSpec {
107 pub column: String,
109 pub kind: ReducerKind,
111}
112
113impl ReducerSpec {
114 pub fn check_label(&self, stats: &[StatDescriptor]) -> Result<(), ReducerError> {
123 if names_a_stat(&self.column, stats) {
124 Ok(())
125 } else {
126 Err(ReducerError::UnknownColumn {
127 column: self.column.clone(),
128 known: stats.iter().map(|stat| stat.label.to_owned()).collect(),
129 })
130 }
131 }
132}
133
134impl FromStr for ReducerSpec {
135 type Err = ReducerError;
136
137 fn from_str(raw: &str) -> Result<Self, Self::Err> {
139 let (column, kind) = raw
140 .rsplit_once(':')
141 .ok_or_else(|| ReducerError::MissingKind { raw: raw.to_owned() })?;
142 Ok(Self {
143 column: column.to_owned(),
144 kind: kind.parse()?,
145 })
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum ReducerError {
152 MissingKind {
154 raw: String,
156 },
157 UnknownKind {
159 raw: String,
161 },
162 Comparison {
164 raw: String,
166 source: ComparisonError,
168 },
169 BadWindow {
171 raw: String,
173 },
174 UnknownColumn {
176 column: String,
178 known: Vec<String>,
180 },
181}
182
183impl fmt::Display for ReducerError {
184 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185 match self {
186 Self::MissingKind { raw } => write!(f, "invalid reducer '{raw}', expected COLUMN:KIND"),
187 Self::UnknownKind { raw } => write!(
188 f,
189 "unknown reducer kind '{raw}', expected one of final, min, max, mean, argmax, argmin, first<=VALUE, \
190 mean@START..END"
191 ),
192 Self::Comparison {
193 raw,
194 source: source @ ComparisonError::BadThreshold { .. },
195 } => {
196 write!(f, "invalid reducer kind '{raw}', {source}")
197 }
198 Self::Comparison { raw, .. } => write!(
199 f,
200 "invalid reducer kind '{raw}', expected 'first' followed by <, <=, >, >=, == or != and a number"
201 ),
202 Self::BadWindow { raw } => write!(f, "invalid reducer kind '{raw}', expected mean@START..END"),
203 Self::UnknownColumn { column, known } => {
204 write!(
205 f,
206 "unknown stat column '{column}', expected one of {}",
207 known.join(", ")
208 )
209 }
210 }
211 }
212}
213
214impl std::error::Error for ReducerError {
215 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
216 match self {
217 Self::Comparison { source, .. } => Some(source),
218 Self::MissingKind { .. }
219 | Self::UnknownKind { .. }
220 | Self::BadWindow { .. }
221 | Self::UnknownColumn { .. } => None,
222 }
223 }
224}
225
226#[derive(Debug, Clone, Default, PartialEq)]
228pub struct ReducerPlan {
229 columns: Vec<usize>,
230 kinds: Vec<ReducerKind>,
231 names: Vec<String>,
232}
233
234impl ReducerPlan {
235 pub fn bind(columns: &StatColumns, specs: &[ReducerSpec], defaults: bool) -> Result<Self, ReducerError> {
244 let mut plan = Self::default();
245 if defaults {
246 for column in (0..columns.len()).filter(|&column| !columns.is_bucket(column)) {
247 for kind in ReducerKind::DEFAULTS {
248 plan.add(columns, column, kind);
249 }
250 }
251 }
252 for spec in specs {
253 let column = columns
254 .resolve(&spec.column)
255 .ok_or_else(|| ReducerError::UnknownColumn {
256 column: spec.column.clone(),
257 known: (0..columns.len())
258 .map(|column| columns.name(column).to_owned())
259 .collect(),
260 })?;
261 plan.add(columns, column, spec.kind);
262 }
263 Ok(plan)
264 }
265
266 fn add(&mut self, columns: &StatColumns, column: usize, kind: ReducerKind) {
267 let bound = self
268 .columns
269 .iter()
270 .zip(&self.kinds)
271 .any(|(&bound_column, &bound_kind)| bound_column == column && bound_kind == kind);
272 if !bound {
273 self.columns.push(column);
274 self.kinds.push(kind);
275 self.names.push(format!("{}:{kind}", columns.name(column)));
276 }
277 }
278
279 pub fn len(&self) -> usize {
281 self.kinds.len()
282 }
283
284 pub fn is_empty(&self) -> bool {
286 self.kinds.is_empty()
287 }
288
289 pub fn names(&self) -> &[String] {
291 &self.names
292 }
293
294 pub fn column(&self, i: usize) -> usize {
300 self.columns[i]
301 }
302
303 pub fn kind(&self, i: usize) -> ReducerKind {
309 self.kinds[i]
310 }
311}
312
313#[derive(Debug, Clone, PartialEq)]
315pub struct ReducerState {
316 values: Vec<f64>,
318 ticks: Vec<u64>,
320 counts: Vec<u64>,
323}
324
325impl ReducerState {
326 pub fn new(plan: &ReducerPlan) -> Self {
328 Self {
329 values: vec![0.0; plan.len()],
330 ticks: vec![0; plan.len()],
331 counts: vec![0; plan.len()],
332 }
333 }
334
335 pub fn push(&mut self, plan: &ReducerPlan, tick: u64, row: &[f64]) {
338 for (i, ((value, kept_tick), count)) in self
339 .values
340 .iter_mut()
341 .zip(&mut self.ticks)
342 .zip(&mut self.counts)
343 .enumerate()
344 {
345 let sample = row[plan.columns[i]];
346 if !sample.is_finite() {
347 continue;
348 }
349 let replaces = match plan.kinds[i] {
350 ReducerKind::Final => true,
351 ReducerKind::Min | ReducerKind::ArgMin => *count == 0 || sample < *value,
352 ReducerKind::Max | ReducerKind::ArgMax => *count == 0 || sample > *value,
353 ReducerKind::Mean => {
354 *value += sample;
355 false
356 }
357 ReducerKind::FirstCrossing(comparison) => {
358 if *count == 0 && comparison.holds(sample) {
359 *value = tick as f64;
360 *count = 1;
361 }
362 continue;
363 }
364 ReducerKind::WindowMean { start, end } => {
365 if tick < start || tick > end {
366 continue;
367 }
368 *value += sample;
369 false
370 }
371 };
372 if replaces {
373 *value = sample;
374 *kept_tick = tick;
375 }
376 *count += 1;
377 }
378 }
379
380 pub fn finish(&self, plan: &ReducerPlan) -> Vec<Option<f64>> {
382 self.values
383 .iter()
384 .zip(&self.ticks)
385 .zip(&self.counts)
386 .zip(&plan.kinds)
387 .map(|(((&value, &tick), &count), kind)| match (count, kind) {
388 (0, _) => None,
389 (_, ReducerKind::Mean | ReducerKind::WindowMean { .. }) => Some(value / count as f64),
390 (_, ReducerKind::ArgMax | ReducerKind::ArgMin) => Some(tick as f64),
391 _ => Some(value),
392 })
393 .collect()
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::{ReducerError, ReducerKind, ReducerPlan, ReducerSpec, ReducerState};
400 use crate::export::StatColumns;
401 use crate::helpers::{stat, stat_histogram, stat_vec2};
402 use crate::view::StatDescriptor;
403
404 const COLOR: [u8; 4] = [0, 0, 0, 255];
405
406 fn columns() -> StatColumns {
407 StatColumns::plan(&[
408 stat("Infected", 0.0, COLOR),
409 stat_vec2("Velocity", 0.0, 0.0, COLOR),
410 stat_histogram("Speed", vec![0.0, 1.0, 2.0], vec![0, 0], COLOR),
411 ])
412 }
413
414 fn spec(raw: &str) -> ReducerSpec {
415 raw.parse().expect("a well-formed reducer")
416 }
417
418 #[test]
419 fn the_core_reducers_match_a_hand_computed_series() {
420 let columns = StatColumns::plan(&[stat("A", 0.0, COLOR)]);
421 let plan = ReducerPlan::bind(&columns, &[], true).expect("defaults always bind");
422 assert_eq!(plan.names(), ["A:final", "A:min", "A:max", "A:mean"]);
423 let mut state = ReducerState::new(&plan);
424 for (tick, value) in (0..).zip([3.0, 1.0, 4.0, 1.0, 5.0]) {
425 state.push(&plan, tick, &[value]);
426 }
427 assert_eq!(state.finish(&plan), [Some(5.0), Some(1.0), Some(5.0), Some(2.8)]);
428 }
429
430 #[test]
431 fn a_reducer_that_sees_no_finite_value_is_empty() {
432 let columns = StatColumns::plan(&[stat("A", 0.0, COLOR)]);
433 let plan = ReducerPlan::bind(&columns, &[], true).expect("defaults always bind");
434 let mut state = ReducerState::new(&plan);
435 assert_eq!(state.finish(&plan), [None; 4], "no samples");
436 state.push(&plan, 0, &[f64::NAN]);
437 state.push(&plan, 1, &[f64::INFINITY]);
438 assert_eq!(state.finish(&plan), [None; 4], "no finite samples");
439 }
440
441 fn reduce(kinds: &[&str], samples: &[(u64, f64)]) -> Vec<Option<f64>> {
443 let columns = StatColumns::plan(&[stat("A", 0.0, COLOR)]);
444 let specs: Vec<ReducerSpec> = kinds.iter().map(|kind| spec(&format!("A:{kind}"))).collect();
445 let plan = ReducerPlan::bind(&columns, &specs, false).expect("column A exists");
446 let mut state = ReducerState::new(&plan);
447 for &(tick, value) in samples {
448 state.push(&plan, tick, &[value]);
449 }
450 state.finish(&plan)
451 }
452
453 #[test]
454 fn argmax_takes_the_first_tick_of_the_maximum() {
455 let samples = [(0, 2.0), (5, 7.0), (10, 1.0), (15, 7.0), (20, 1.0), (25, f64::NAN)];
456 assert_eq!(reduce(&["argmax", "argmin"], &samples), [Some(5.0), Some(10.0)]);
457 assert_eq!(reduce(&["argmax"], &[]), [None]);
458 assert_eq!(
459 reduce(&["argmax"], &[(0, f64::NAN), (5, f64::INFINITY)]),
460 [None],
461 "no finite sample"
462 );
463 }
464
465 #[test]
466 fn first_crossing_reports_the_first_sampled_tick() {
467 let samples = [(0, 30.0), (5, 12.0), (10, 9.0), (15, 20.0), (20, 4.0)];
468 assert_eq!(
469 reduce(&["first<=10", "first>12", "first==12", "first!=30"], &samples),
470 [Some(10.0), Some(0.0), Some(5.0), Some(5.0)]
471 );
472 assert_eq!(
473 reduce(&["first<10"], &[(0, f64::NAN), (5, 3.0)]),
474 [Some(5.0)],
475 "a NaN sample never crosses"
476 );
477 }
478
479 #[test]
480 fn a_crossing_that_never_happens_is_empty() {
481 assert_eq!(reduce(&["first<0"], &[(0, 1.0), (5, 0.0), (10, 2.0)]), [None]);
482 assert_eq!(reduce(&["first<0"], &[]), [None]);
483 }
484
485 #[test]
486 fn a_window_mean_covers_only_its_ticks() {
487 let samples = [(0, 100.0), (5, 1.0), (10, 2.0), (15, 3.0), (20, 100.0)];
488 assert_eq!(
489 reduce(&["mean@5..15", "mean@10..10", "mean@21..40", "mean"], &samples),
490 [Some(2.0), Some(2.0), None, Some(41.2)]
491 );
492 assert_eq!(
493 reduce(&["mean@0..10"], &[(0, 4.0), (5, f64::NAN), (10, 2.0)]),
494 [Some(3.0)],
495 "a value that is not finite is skipped"
496 );
497 }
498
499 #[test]
500 fn every_kind_reads_back_from_its_name() {
501 for name in [
502 "final",
503 "min",
504 "max",
505 "mean",
506 "argmax",
507 "argmin",
508 "first<=10",
509 "first>0.5",
510 "first==-2",
511 "first!=0",
512 "mean@200..600",
513 "mean@0..0",
514 ] {
515 let kind: ReducerKind = name.parse().expect("a known kind");
516 assert_eq!(kind.to_string(), name);
517 }
518 assert_eq!(
519 spec("Infected:first <= 10").kind.to_string(),
520 "first<=10",
521 "spaces around the comparator are dropped"
522 );
523 for bad in [
524 "first",
525 "first<=x",
526 "first=10",
527 "mean@",
528 "mean@600..200",
529 "mean@1..",
530 "mean@a..b",
531 ] {
532 let error = bad.parse::<ReducerKind>().expect_err("refused");
533 assert!(
534 matches!(error, ReducerError::Comparison { .. } | ReducerError::BadWindow { .. }),
535 "{bad} gave {error:?}"
536 );
537 }
538 let infinite = "first<=inf".parse::<ReducerKind>().expect_err("refused");
539 assert_eq!(
540 infinite.to_string(),
541 "invalid reducer kind 'first<=inf', threshold 'inf' is not a finite number",
542 "the message names the threshold, not the format"
543 );
544 }
545
546 #[test]
547 fn defaults_skip_buckets_and_specs_add_to_them() {
548 let columns = columns();
549 let plan = ReducerPlan::bind(&columns, &[spec("Infected:max"), spec("Velocity:mean")], true)
550 .expect("both columns exist");
551 let names: Vec<&str> = plan.names().iter().map(String::as_str).collect();
552 assert_eq!(
553 names.len(),
554 5 * 4,
555 "Infected, the three vector parts and the histogram total"
556 );
557 assert!(
558 !names.iter().any(|name| name.starts_with("Speed.[")),
559 "no bucket gets a default"
560 );
561 assert!(names.contains(&"Speed.total:mean"));
562 assert_eq!(
563 names.iter().filter(|&&name| name == "Infected:max").count(),
564 1,
565 "a spec repeating a default binds once"
566 );
567
568 let plan = ReducerPlan::bind(&columns, &[spec("Velocity:max"), spec("Speed.[0, 1):min")], false)
569 .expect("both columns exist");
570 assert_eq!(plan.names(), ["Velocity.magnitude:max", "Speed.[0, 1):min"]);
571 assert_eq!(plan.kind(1), ReducerKind::Min);
572 }
573
574 #[test]
575 fn a_reducer_is_read_as_column_and_kind() {
576 assert_eq!(
577 spec("Giant Component: Share:mean"),
578 ReducerSpec {
579 column: "Giant Component: Share".to_owned(),
580 kind: ReducerKind::Mean,
581 }
582 );
583 assert_eq!(
584 "Infected".parse::<ReducerSpec>(),
585 Err(ReducerError::MissingKind {
586 raw: "Infected".to_owned()
587 })
588 );
589 assert_eq!(
590 "Infected:median".parse::<ReducerSpec>(),
591 Err(ReducerError::UnknownKind {
592 raw: "median".to_owned()
593 })
594 );
595 for kind in ReducerKind::DEFAULTS {
596 assert_eq!(kind.to_string().parse(), Ok(kind));
597 }
598 }
599
600 #[test]
601 fn an_unknown_column_is_refused() {
602 let error = ReducerPlan::bind(&columns(), &[spec("Recovered:max")], false).expect_err("no such column");
603 assert!(
604 error
605 .to_string()
606 .starts_with("unknown stat column 'Recovered', expected one of Infected, "),
607 "{error}"
608 );
609
610 let stats = [
611 StatDescriptor::new("Infected", COLOR),
612 StatDescriptor::new("Velocity", COLOR),
613 ];
614 assert_eq!(spec("Infected:max").check_label(&stats), Ok(()));
615 assert_eq!(spec("Velocity.x:max").check_label(&stats), Ok(()));
616 assert!(spec("Infectedness:max").check_label(&stats).is_err());
617 assert!(spec("Recovered:max").check_label(&stats).is_err());
618 }
619}