use std::collections::BTreeMap;
use crate::core::{Digest, Timestamp};
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct TrackRecord {
pub capability: String,
pub since: Timestamp,
pub until: Timestamp,
pub revisions: Vec<RevisionRecord>,
}
impl TrackRecord {
#[must_use]
pub const fn new(capability: String, since: Timestamp, until: Timestamp) -> Self {
Self {
capability,
since,
until,
revisions: Vec::new(),
}
}
#[must_use]
pub fn total(&self) -> Track {
let mut total = Track::default();
for revision in &self.revisions {
total.merge(&revision.track);
}
total
}
pub fn revision_mut(&mut self, agent: Option<Revision>) -> &mut Track {
let at = match self
.revisions
.binary_search_by(|held| held.agent.cmp(&agent))
{
Ok(at) => at,
Err(at) => {
self.revisions.insert(
at,
RevisionRecord {
agent,
track: Track::default(),
},
);
at
}
};
&mut self.revisions[at].track
}
}
pub(crate) fn run_window(since: Timestamp, until: Timestamp) -> Option<(String, String)> {
let from = crate::core::RunId::first_at(since).to_string();
let to = crate::core::RunId::first_at(until).to_string();
(from < to).then_some((from, to))
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct RevisionRecord {
pub agent: Option<Revision>,
pub track: Track,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub struct Revision {
pub name: String,
pub version: String,
pub digest: Digest,
}
impl Revision {
#[must_use]
pub const fn new(name: String, version: String, digest: Digest) -> Self {
Self {
name,
version,
digest,
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
#[non_exhaustive]
pub struct Track {
pub admitted: u64,
pub outcomes: BTreeMap<String, u64>,
pub metrics: BTreeMap<String, MetricSummary>,
}
impl Track {
#[must_use]
pub fn open(&self) -> u64 {
self.admitted
.saturating_sub(self.outcomes.values().sum::<u64>())
}
pub fn merge(&mut self, other: &Self) {
self.admitted += other.admitted;
for (outcome, runs) in &other.outcomes {
*self.outcomes.entry(outcome.clone()).or_default() += runs;
}
for (metric, summary) in &other.metrics {
self.metrics
.entry(metric.clone())
.or_default()
.merge(summary);
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
#[non_exhaustive]
pub struct MetricSummary {
pub runs: u64,
pub passed: u64,
pub failed: u64,
pub labels: BTreeMap<String, u64>,
pub numbers: Option<NumberSummary>,
}
impl MetricSummary {
#[must_use]
pub fn verdicts(&self) -> u64 {
self.passed
+ self.failed
+ self.labels.values().sum::<u64>()
+ self.numbers.as_ref().map_or(0, |n| n.count)
}
pub fn merge(&mut self, other: &Self) {
self.runs += other.runs;
self.passed += other.passed;
self.failed += other.failed;
for (label, count) in &other.labels {
*self.labels.entry(label.clone()).or_default() += count;
}
if let Some(theirs) = &other.numbers {
match &mut self.numbers {
Some(ours) => ours.merge(theirs),
None => self.numbers = Some(theirs.clone()),
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct NumberSummary {
pub count: u64,
pub sum: f64,
pub min: f64,
pub max: f64,
}
impl NumberSummary {
#[must_use]
pub const fn new(count: u64, sum: f64, min: f64, max: f64) -> Self {
Self {
count,
sum,
min,
max,
}
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn mean(&self) -> Option<f64> {
(self.count > 0).then(|| self.sum / self.count as f64)
}
pub fn merge(&mut self, other: &Self) {
self.count += other.count;
self.sum += other.sum;
self.min = self.min.min(other.min);
self.max = self.max.max(other.max);
}
}
#[cfg(test)]
mod tests {
use super::{MetricSummary, NumberSummary, Revision, Track, TrackRecord};
use crate::core::{Digest, Timestamp};
fn at(s: i64) -> Timestamp {
Timestamp::from_unix_timestamp(s).unwrap()
}
#[test]
fn the_total_folds_every_revision_and_open_is_what_did_not_conclude() {
let mut record = TrackRecord::new("triage".into(), at(0), at(10));
let v1 = Revision::new("triage".into(), "1".into(), Digest::ZERO);
{
let t = record.revision_mut(Some(v1.clone()));
t.admitted = 3;
t.outcomes.insert("succeeded".into(), 2);
t.metrics.insert(
"score".into(),
MetricSummary {
runs: 1,
numbers: Some(NumberSummary::new(1, 4.0, 4.0, 4.0)),
..MetricSummary::default()
},
);
}
{
let t = record.revision_mut(None);
t.admitted = 2;
t.outcomes.insert("failed".into(), 1);
t.metrics.insert(
"score".into(),
MetricSummary {
runs: 1,
passed: 1,
numbers: Some(NumberSummary::new(1, 1.0, 1.0, 1.0)),
..MetricSummary::default()
},
);
}
assert_eq!(record.revisions[0].agent, None, "undeclared sorts first");
assert_eq!(
record.revision_mut(Some(v1)).admitted,
3,
"found, not added"
);
assert_eq!(record.revisions.len(), 2);
let total: Track = record.total();
assert_eq!(total.admitted, 5);
assert_eq!(total.open(), 2);
let score = &total.metrics["score"];
assert_eq!(score.runs, 2);
assert_eq!(score.verdicts(), 3);
let numbers = score.numbers.as_ref().unwrap();
assert_eq!((numbers.min, numbers.max), (1.0, 4.0));
assert_eq!(numbers.mean(), Some(2.5));
}
}