botstadia-interface 0.1.0

BotStadia's shared contracts: arena and agent WIT worlds, arena declarations, payloads, replay archives, metrics files and player messages.
Documentation
//! The metrics file: everything the site draws about a session, beside its
//! replay archive.
//!
//! It carries the platform's per-turn metrics and the arena's declared series,
//! events, stats and result, so the site draws a replay page from this file
//! and the declaration alone, and never decodes an arena's frames. A series
//! that would repeat itself is recorded on change, to keep the file small.
//!
//! JSON, since the site reads it on every visit and it stays small.

use serde::{Deserialize, Serialize};

use crate::declaration::{Declaration, SeriesScope};

/// Bumped on any change to these types.
pub const METRICS_FORMAT: u32 = 1;

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Metrics {
    pub format: u32,
    pub arena_id: String,
    pub arena_version: String,
    pub seats: u32,
    /// Turns played after turn 0.
    pub turns: u32,
    pub control_rate: u32,
    /// The arena's declared series, by key.
    pub series: Vec<SeriesTrack>,
    /// The platform's own per-turn series (fuel, wall time, misses, …), by key.
    pub platform: Vec<SeriesTrack>,
    pub events: Vec<EventMark>,
    /// The limits the session ran under, worded for the page.
    pub limits: Vec<String>,
    /// Set once the session finished with a result.
    pub result: Option<SessionResult>,
}

/// One series' values, recorded on change.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SeriesTrack {
    pub key: String,
    /// One line per seat, or one for the whole session.
    pub lines: Vec<Line>,
}

/// A line's points, each `(turn, value)`, recorded only when the value
/// changes. A value holds until the next point.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Line {
    pub seat: Option<u32>,
    pub points: Vec<(u32, f32)>,
}

impl Line {
    /// The value at `turn`.
    pub fn at(&self, turn: u32) -> Option<f32> {
        let after = self.points.partition_point(|(at, _)| *at <= turn);
        after.checked_sub(1).map(|index| self.points[index].1)
    }

    /// Every turn's value from 0 to `turns`, filling held values in.
    pub fn dense(&self, turns: u32) -> Vec<f32> {
        (0..=turns)
            .map(|turn| self.at(turn).unwrap_or(0.0))
            .collect()
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EventMark {
    pub turn: u32,
    pub key: String,
    pub seat: Option<u32>,
}

/// How a session finished.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionResult {
    /// Places from first to last, each listing the seats that share it.
    pub places: Vec<Vec<u32>>,
    pub decided_by: String,
    /// Each declared stat's values, one per seat, by key.
    pub stats: Vec<StatValues>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StatValues {
    pub key: String,
    pub values: Vec<f64>,
}

impl SessionResult {
    /// Seats from first place to last, with how many share first.
    pub fn finish(&self) -> (Vec<u32>, usize) {
        let order = self.places.iter().flatten().copied().collect();
        (order, self.places.first().map_or(0, Vec::len))
    }
}

impl Metrics {
    pub fn to_json(&self) -> Vec<u8> {
        serde_json::to_vec(self).expect("metrics always serialise")
    }

    pub fn from_json(bytes: &[u8]) -> Result<Self, serde_json::Error> {
        serde_json::from_slice(bytes)
    }

    pub fn series(&self, key: &str) -> Option<&SeriesTrack> {
        self.series.iter().find(|track| track.key == key)
    }
}

/// Builds a metrics file turn by turn, from the arena's reports, recording
/// each line only on change.
#[derive(Debug, Clone)]
pub struct MetricsRecorder {
    metrics: Metrics,
    keys: Vec<String>,
}

impl MetricsRecorder {
    pub fn new(declaration: &Declaration, seats: u32) -> Self {
        let series = declaration
            .series
            .iter()
            .map(|decl| SeriesTrack {
                key: decl.key.clone(),
                lines: match decl.scope {
                    SeriesScope::PerSeat => (0..seats)
                        .map(|seat| Line {
                            seat: Some(seat),
                            points: Vec::new(),
                        })
                        .collect(),
                    SeriesScope::Session => vec![Line {
                        seat: None,
                        points: Vec::new(),
                    }],
                },
            })
            .collect();
        Self {
            metrics: Metrics {
                format: METRICS_FORMAT,
                arena_id: declaration.arena_id.clone(),
                arena_version: declaration.arena_version.clone(),
                seats,
                turns: 0,
                control_rate: declaration.control_rate,
                series,
                platform: Vec::new(),
                events: Vec::new(),
                limits: Vec::new(),
                result: None,
            },
            keys: declaration
                .events
                .iter()
                .map(|event| event.key.clone())
                .collect(),
        }
    }

    /// Record one turn's series values (one entry per declared series, each
    /// with one value per line) and events (by declared index).
    pub fn turn(
        &mut self,
        turn: u32,
        series: &[Vec<f32>],
        events: &[(u32, Option<u32>)],
    ) -> Result<(), String> {
        if series.len() != self.metrics.series.len() {
            return Err(format!(
                "{} series reported, {} declared",
                series.len(),
                self.metrics.series.len()
            ));
        }
        for (track, values) in self.metrics.series.iter_mut().zip(series) {
            if values.len() != track.lines.len() {
                return Err(format!(
                    "series `{}` reported {} values for {} lines",
                    track.key,
                    values.len(),
                    track.lines.len()
                ));
            }
            for (line, value) in track.lines.iter_mut().zip(values) {
                if line
                    .points
                    .last()
                    .is_none_or(|(_, last)| last.to_bits() != value.to_bits())
                {
                    line.points.push((turn, *value));
                }
            }
        }
        for (index, seat) in events {
            let key = self
                .keys
                .get(*index as usize)
                .ok_or_else(|| format!("event {index} isn't declared"))?;
            self.metrics.events.push(EventMark {
                turn,
                key: key.clone(),
                seat: *seat,
            });
        }
        self.metrics.turns = turn;
        Ok(())
    }

    pub fn limits(&mut self, limits: Vec<String>) {
        self.metrics.limits = limits;
    }

    pub fn platform(&mut self, track: SeriesTrack) {
        self.metrics.platform.push(track);
    }

    pub fn finish(
        mut self,
        declaration: &Declaration,
        places: Vec<Vec<u32>>,
        decided_by: String,
        stats: Vec<Vec<f64>>,
    ) -> Result<Metrics, String> {
        if stats.len() != declaration.stats.len() {
            return Err(format!(
                "{} stats reported, {} declared",
                stats.len(),
                declaration.stats.len()
            ));
        }
        let seats = self.metrics.seats;
        let mut seen: Vec<u32> = places.iter().flatten().copied().collect();
        seen.sort_unstable();
        if seen != (0..seats).collect::<Vec<_>>() || places.iter().any(Vec::is_empty) {
            return Err(format!(
                "places {places:?} must list every seat exactly once"
            ));
        }
        let stats = declaration
            .stats
            .iter()
            .zip(stats)
            .map(|(decl, values)| {
                if values.len() == seats as usize {
                    Ok(StatValues {
                        key: decl.key.clone(),
                        values,
                    })
                } else {
                    Err(format!(
                        "stat `{}` has {} values for {seats} seats",
                        decl.key,
                        values.len()
                    ))
                }
            })
            .collect::<Result<_, _>>()?;
        self.metrics.result = Some(SessionResult {
            places,
            decided_by,
            stats,
        });
        Ok(self.metrics)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::declaration::tests::sample;

    #[test]
    fn records_on_change_and_reads_back_densely() {
        let declaration = sample();
        let mut recorder = MetricsRecorder::new(&declaration, 2);
        for (turn, value) in [(0, 1.0), (1, 1.0), (2, 3.0), (3, 3.0)] {
            recorder.turn(turn, &[vec![value]], &[]).unwrap();
        }
        recorder.turn(4, &[vec![3.0]], &[(0, Some(1))]).unwrap();
        let metrics = recorder
            .finish(
                &declaration,
                vec![vec![1], vec![0]],
                "By hits".into(),
                vec![vec![1.0, 4.0]],
            )
            .unwrap();
        let line = &metrics.series("distance").unwrap().lines[0];
        assert_eq!(line.points, vec![(0, 1.0), (2, 3.0)]);
        assert_eq!(line.dense(4), vec![1.0, 1.0, 3.0, 3.0, 3.0]);
        assert_eq!(
            metrics.events,
            vec![EventMark {
                turn: 4,
                key: "hit".into(),
                seat: Some(1)
            }]
        );
        assert_eq!(metrics.result.as_ref().unwrap().finish(), (vec![1, 0], 1));
        assert_eq!(Metrics::from_json(&metrics.to_json()).unwrap(), metrics);
    }

    #[test]
    fn rejects_undeclared_reports_and_partial_places() {
        let declaration = sample();
        let mut recorder = MetricsRecorder::new(&declaration, 2);
        assert!(recorder.turn(1, &[], &[]).is_err());
        assert!(recorder.turn(1, &[vec![0.0]], &[(7, None)]).is_err());
        assert!(
            recorder
                .clone()
                .finish(
                    &declaration,
                    vec![vec![0]],
                    "x".into(),
                    vec![vec![0.0, 0.0]]
                )
                .is_err()
        );
        assert!(
            recorder
                .finish(
                    &declaration,
                    vec![vec![0, 1]],
                    "Draw".into(),
                    vec![vec![0.0, 0.0]]
                )
                .is_ok()
        );
    }
}