sim: add output trace recording

This commit is contained in:
ApsisRS contributors 2026-09-26 22:50:05 +08:00
parent f56ec891a0
commit 2ebd85a434
7 changed files with 763 additions and 0 deletions

3
Cargo.lock generated
View File

@ -198,6 +198,9 @@ dependencies = [
[[package]]
name = "apsis-output"
version = "0.1.0"
dependencies = [
"apsis-core",
]
[[package]]
name = "apsis-resource"

View File

@ -11,3 +11,4 @@ publish = false
workspace = true
[dependencies]
apsis-core.workspace = true

View File

@ -1 +1,45 @@
//! Simulation output recording.
//!
//! A simulation produces more output than a caller can act on at once: event
//! times, the values a model computes as it runs, and the warnings it raises.
//! This crate collects those into one trace, so that a run can be examined
//! after it finishes rather than only as it happens.
//!
//! A record has a time, a kind, a label, and a value. The time is the
//! simulation instant the record was made at, which is what makes a trace
//! orderable and joinable against the event log. The kind separates the three
//! things a caller usually wants to tell apart: an observation the model made,
//! a state change it decided on, and a warning it wants to surface. The label
//! names what the value is about, and the value is a single number, which
//! covers the great majority of what a simulation records without imposing a
//! schema the caller has to design.
//!
//! The trace keeps the records in the order they were made, not in time order.
//! That distinction matters: a handler may record a value for an event it is
//! handling after recording one for a follow-up it just scheduled, and the
//! order the model reasoned in is worth keeping. A caller that wants time
//! order asks for it with [`Trace::in_time_order`].
//!
//! ```
//! use apsis_output::{Recorder, RecordKind};
//!
//! let mut recorder = Recorder::new();
//! recorder.observe(10.0, "altitude", 550.0);
//! recorder.note(10.0, "eclipse", 1.0);
//!
//! assert_eq!(recorder.len(), 2);
//! assert!(recorder.trace().is_stable());
//! assert_eq!(recorder.trace().value_of("altitude"), Some(550.0));
//! let _ = RecordKind::Warning;
//! ```
#![forbid(unsafe_code)]
#![deny(missing_docs)]
mod record;
mod recorder;
mod trace;
pub use record::{Record, RecordKind};
pub use recorder::Recorder;
pub use trace::{SeriesSummary, Trace};

View File

@ -0,0 +1,102 @@
//! One recorded value.
use core::fmt;
/// What a record is about.
///
/// The three kinds separate the things a caller usually wants to tell apart
/// when it reads a trace back: a value the model measured, a decision it
/// made, and a problem it wants to surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum RecordKind {
/// A value the model measured, such as a range or a rate.
Observation,
/// A change the model decided on, such as entering eclipse.
State,
/// A condition worth surfacing, such as a value outside its bounds.
Warning,
}
impl RecordKind {
/// Returns a short name for the kind.
pub const fn name(self) -> &'static str {
match self {
Self::Observation => "observation",
Self::State => "state",
Self::Warning => "warning",
}
}
/// Returns `true` when the kind is a warning.
pub const fn is_warning(self) -> bool {
matches!(self, Self::Warning)
}
}
impl fmt::Display for RecordKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}
/// One recorded value.
///
/// A record is small and copied by value, so a trace holds a flat list rather
/// than a tree of references.
#[derive(Debug, Clone, PartialEq)]
pub struct Record {
/// The simulation time the record was made at, in seconds.
pub time: f64,
/// What the record is about.
pub kind: RecordKind,
/// What the value is about.
pub label: String,
/// The value recorded.
pub value: f64,
}
impl Record {
/// Creates a record.
pub fn new(time: f64, kind: RecordKind, label: impl Into<String>, value: f64) -> Self {
Self {
time,
kind,
label: label.into(),
value,
}
}
/// Creates an observation record.
pub fn observation(time: f64, label: impl Into<String>, value: f64) -> Self {
Self::new(time, RecordKind::Observation, label, value)
}
/// Creates a state record.
pub fn state(time: f64, label: impl Into<String>, value: f64) -> Self {
Self::new(time, RecordKind::State, label, value)
}
/// Creates a warning record.
pub fn warning(time: f64, label: impl Into<String>, value: f64) -> Self {
Self::new(time, RecordKind::Warning, label, value)
}
/// Returns `true` when the record is usable.
///
/// A record whose time or value is not finite has no place in a trace,
/// because it cannot be ordered or summarized.
pub fn is_valid(&self) -> bool {
self.time.is_finite() && self.value.is_finite() && !self.label.is_empty()
}
}
impl fmt::Display for Record {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} {} {} = {}",
self.time, self.kind, self.label, self.value
)
}
}

View File

@ -0,0 +1,108 @@
//! Building a trace as a simulation runs.
use crate::record::{Record, RecordKind};
use crate::trace::Trace;
/// Collects records as a simulation runs.
///
/// The recorder is the write side of a trace: a handler calls one of its
/// methods for each value it wants to keep, and asks for the trace when the
/// run finishes. The two are separate so that a handler can hold a recorder
/// while the trace is read elsewhere.
///
/// ```
/// use apsis_output::Recorder;
///
/// let mut recorder = Recorder::new();
/// recorder.observe(0.0, "range", 1_000.0);
/// recorder.observe(10.0, "range", 950.0);
///
/// let summary = recorder.trace().summarize("range").expect("the series exists");
/// assert_eq!(summary.count, 2);
/// assert_eq!(summary.change, -50.0);
/// ```
#[derive(Debug, Clone, Default)]
pub struct Recorder {
trace: Trace,
refused: u64,
}
impl Recorder {
/// Creates an empty recorder.
pub const fn new() -> Self {
Self {
trace: Trace::new(),
refused: 0,
}
}
/// Records a value of a given kind.
///
/// Returns `false` when the record is not usable, which a caller can
/// ignore when the value is known to be finite.
pub fn record(
&mut self,
time: f64,
kind: RecordKind,
label: impl Into<String>,
value: f64,
) -> bool {
let accepted = self.trace.push(Record::new(time, kind, label, value));
if !accepted {
self.refused = self.refused.wrapping_add(1);
}
accepted
}
/// Records an observation.
pub fn observe(&mut self, time: f64, label: impl Into<String>, value: f64) -> bool {
self.record(time, RecordKind::Observation, label, value)
}
/// Records a state change.
pub fn note(&mut self, time: f64, label: impl Into<String>, value: f64) -> bool {
self.record(time, RecordKind::State, label, value)
}
/// Records a warning.
pub fn warn(&mut self, time: f64, label: impl Into<String>, value: f64) -> bool {
self.record(time, RecordKind::Warning, label, value)
}
/// Returns the number of records made.
pub fn len(&self) -> usize {
self.trace.len()
}
/// Returns `true` when no records have been made.
pub fn is_empty(&self) -> bool {
self.trace.is_empty()
}
/// Returns the number of records refused because they were not usable.
///
/// A caller that records values from a model checks this to see whether
/// any value was dropped, rather than comparing counts by hand.
pub const fn refused(&self) -> u64 {
self.refused
}
/// Returns the trace recorded so far.
pub const fn trace(&self) -> &Trace {
&self.trace
}
/// Returns the trace, leaving an empty one behind.
///
/// This is what a caller does between runs: the finished trace goes to
/// whoever reads it, and the recorder starts fresh. The refused count is
/// not reset, because it records the whole life of the recorder.
pub fn take_trace(&mut self) -> Trace {
core::mem::take(&mut self.trace)
}
/// Removes every record without changing the refused count.
pub fn clear(&mut self) -> usize {
self.trace.clear()
}
}

View File

@ -0,0 +1,226 @@
//! The trace and its summaries.
use crate::record::{Record, RecordKind};
use std::collections::BTreeMap;
/// A list of records in the order they were made.
///
/// The trace preserves insertion order rather than sorting by time, because
/// the order a model recorded in is part of what it decided. A caller that
/// needs time order asks for [`Trace::in_time_order`], which leaves the
/// original alone.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Trace {
records: Vec<Record>,
}
impl Trace {
/// Creates an empty trace.
pub const fn new() -> Self {
Self {
records: Vec::new(),
}
}
/// Creates a trace from a list of records.
pub fn from_records(records: Vec<Record>) -> Self {
Self { records }
}
/// Returns the number of records.
pub fn len(&self) -> usize {
self.records.len()
}
/// Returns `true` when there are no records.
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
/// Returns the records in the order they were made.
pub fn records(&self) -> &[Record] {
&self.records
}
/// Adds a record, returning `false` when it is not usable.
///
/// A record whose time or value is not finite is refused rather than
/// stored, so a trace never holds a value that cannot be summarized.
pub fn push(&mut self, record: Record) -> bool {
if !record.is_valid() {
return false;
}
self.records.push(record);
true
}
/// Returns `true` when no record has a time or value that is not finite.
pub fn is_stable(&self) -> bool {
self.records.iter().all(Record::is_valid)
}
/// Returns the records in time order, earliest first.
///
/// Records at the same instant keep their insertion order, so the sort is
/// stable and the result does not depend on the sort algorithm.
pub fn in_time_order(&self) -> Vec<Record> {
let mut ordered = self.records.clone();
ordered.sort_by(|left, right| {
left.time
.partial_cmp(&right.time)
.unwrap_or(core::cmp::Ordering::Equal)
});
ordered
}
/// Returns the records of one kind, in order.
pub fn of_kind(&self, kind: RecordKind) -> Vec<&Record> {
self.records
.iter()
.filter(|record| record.kind == kind)
.collect()
}
/// Returns the records with a label, in order.
pub fn with_label(&self, label: &str) -> Vec<&Record> {
self.records
.iter()
.filter(|record| record.label == label)
.collect()
}
/// Returns the value of the last record with a label.
///
/// This is what a caller asks for after a run: the final value a series
/// reached. Returns `None` when no record has the label.
pub fn value_of(&self, label: &str) -> Option<f64> {
self.records
.iter()
.rev()
.find(|record| record.label == label)
.map(|record| record.value)
}
/// Returns the time of the last record with a label.
pub fn last_time_of(&self, label: &str) -> Option<f64> {
self.records
.iter()
.rev()
.find(|record| record.label == label)
.map(|record| record.time)
}
/// Returns the labels present, in sorted order.
///
/// The order is sorted rather than first seen, so that two traces with
/// the same labels list them the same way.
pub fn labels(&self) -> Vec<&str> {
let mut labels: Vec<&str> = self
.records
.iter()
.map(|record| record.label.as_str())
.collect();
labels.sort_unstable();
labels.dedup();
labels
}
/// Returns a summary of the series recorded under a label.
///
/// Returns `None` when no record has the label.
pub fn summarize(&self, label: &str) -> Option<SeriesSummary> {
let values: Vec<f64> = self
.records
.iter()
.filter(|record| record.label == label)
.map(|record| record.value)
.collect();
if values.is_empty() {
return None;
}
let count = values.len();
let sum: f64 = values.iter().sum();
let min = values.iter().copied().fold(f64::INFINITY, f64::min);
let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let first = values[0];
let last = values[count - 1];
Some(SeriesSummary {
label: label.to_string(),
count,
min,
max,
mean: sum / count as f64,
first,
last,
change: last - first,
})
}
/// Returns the number of warnings in the trace.
pub fn warning_count(&self) -> usize {
self.records
.iter()
.filter(|record| record.kind.is_warning())
.count()
}
/// Returns a summary of every label, in sorted label order.
pub fn summaries(&self) -> Vec<SeriesSummary> {
self.labels()
.into_iter()
.filter_map(|label| self.summarize(label))
.collect()
}
/// Returns the greatest number of records that share a label.
///
/// This is the length of the longest series, which a caller uses to size
/// a table of the trace.
pub fn longest_series(&self) -> usize {
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
for record in &self.records {
*counts.entry(record.label.as_str()).or_insert(0) += 1;
}
counts.values().copied().max().unwrap_or(0)
}
/// Removes every record and returns how many were discarded.
pub fn clear(&mut self) -> usize {
let count = self.records.len();
self.records.clear();
count
}
}
/// A summary of one series of records.
#[derive(Debug, Clone, PartialEq)]
pub struct SeriesSummary {
/// The label the series was recorded under.
pub label: String,
/// The number of records in the series.
pub count: usize,
/// The smallest value recorded.
pub min: f64,
/// The greatest value recorded.
pub max: f64,
/// The mean of the values.
pub mean: f64,
/// The first value recorded.
pub first: f64,
/// The last value recorded.
pub last: f64,
/// The difference between the last and first values.
pub change: f64,
}
impl SeriesSummary {
/// Returns the spread between the greatest and smallest values.
pub fn range(&self) -> f64 {
self.max - self.min
}
/// Returns `true` when the series never changed.
pub fn is_constant(&self) -> bool {
self.max == self.min
}
}

View File

@ -0,0 +1,279 @@
//! Tests for the output trace and recorder.
use apsis_output::{Record, RecordKind, Recorder, Trace};
#[test]
fn an_empty_trace_has_nothing() {
let trace = Trace::new();
assert!(trace.is_empty());
assert_eq!(trace.len(), 0);
assert!(trace.is_stable());
assert!(trace.records().is_empty());
assert_eq!(trace.labels(), Vec::<&str>::new());
assert_eq!(trace.value_of("anything"), None);
assert_eq!(trace.summarize("anything"), None);
assert_eq!(trace.warning_count(), 0);
assert_eq!(trace.longest_series(), 0);
}
#[test]
fn a_trace_keeps_insertion_order() {
// The order a model recorded in is part of what it decided, so the trace
// does not sort.
let mut trace = Trace::new();
trace.push(Record::observation(10.0, "b", 1.0));
trace.push(Record::observation(5.0, "a", 2.0));
let labels: Vec<&str> = trace
.records()
.iter()
.map(|record| record.label.as_str())
.collect();
assert_eq!(labels, vec!["b", "a"]);
}
#[test]
fn time_order_is_available_on_request() {
let mut trace = Trace::new();
trace.push(Record::observation(10.0, "b", 1.0));
trace.push(Record::observation(5.0, "a", 2.0));
trace.push(Record::observation(7.0, "c", 3.0));
let ordered = trace.in_time_order();
let times: Vec<f64> = ordered.iter().map(|record| record.time).collect();
assert_eq!(times, vec![5.0, 7.0, 10.0]);
// The original is unchanged.
assert_eq!(trace.records()[0].time, 10.0);
}
#[test]
fn time_order_is_stable_for_equal_times() {
let mut trace = Trace::new();
trace.push(Record::observation(5.0, "first", 1.0));
trace.push(Record::observation(5.0, "second", 2.0));
trace.push(Record::observation(5.0, "third", 3.0));
let ordered = trace.in_time_order();
let labels: Vec<&str> = ordered.iter().map(|record| record.label.as_str()).collect();
assert_eq!(labels, vec!["first", "second", "third"]);
}
#[test]
fn records_can_be_filtered_by_kind() {
let mut trace = Trace::new();
trace.push(Record::observation(1.0, "a", 1.0));
trace.push(Record::warning(2.0, "b", 2.0));
trace.push(Record::state(3.0, "c", 3.0));
trace.push(Record::warning(4.0, "d", 4.0));
assert_eq!(trace.of_kind(RecordKind::Observation).len(), 1);
assert_eq!(trace.of_kind(RecordKind::Warning).len(), 2);
assert_eq!(trace.of_kind(RecordKind::State).len(), 1);
assert_eq!(trace.warning_count(), 2);
}
#[test]
fn records_can_be_filtered_by_label() {
let mut trace = Trace::new();
trace.push(Record::observation(1.0, "range", 1_000.0));
trace.push(Record::observation(2.0, "range", 900.0));
trace.push(Record::observation(3.0, "rate", 5.0));
assert_eq!(trace.with_label("range").len(), 2);
assert_eq!(trace.with_label("rate").len(), 1);
assert_eq!(trace.with_label("absent").len(), 0);
}
#[test]
fn the_last_value_of_a_label_is_reported() {
let mut trace = Trace::new();
trace.push(Record::observation(1.0, "range", 1_000.0));
trace.push(Record::observation(5.0, "range", 900.0));
trace.push(Record::observation(9.0, "range", 950.0));
assert_eq!(trace.value_of("range"), Some(950.0));
assert_eq!(trace.last_time_of("range"), Some(9.0));
assert_eq!(trace.value_of("absent"), None);
}
#[test]
fn the_labels_are_unique_and_sorted() {
let mut trace = Trace::new();
trace.push(Record::observation(1.0, "zeta", 1.0));
trace.push(Record::observation(2.0, "alpha", 2.0));
trace.push(Record::observation(3.0, "zeta", 3.0));
assert_eq!(trace.labels(), vec!["alpha", "zeta"]);
}
#[test]
fn a_series_summarizes_its_values() {
let mut trace = Trace::new();
for (time, value) in [(0.0, 10.0), (1.0, 20.0), (2.0, 30.0), (3.0, 5.0)] {
trace.push(Record::observation(time, "x", value));
}
let summary = trace.summarize("x").expect("the series exists");
assert_eq!(summary.label, "x");
assert_eq!(summary.count, 4);
assert_eq!(summary.min, 5.0);
assert_eq!(summary.max, 30.0);
assert_eq!(summary.first, 10.0);
assert_eq!(summary.last, 5.0);
assert_eq!(summary.change, -5.0);
assert_eq!(summary.mean, 65.0 / 4.0);
assert_eq!(summary.range(), 25.0);
assert!(!summary.is_constant());
}
#[test]
fn a_constant_series_is_recognized() {
let mut trace = Trace::new();
for time in 0..3 {
trace.push(Record::observation(f64::from(time), "x", 7.0));
}
let summary = trace.summarize("x").expect("the series exists");
assert!(summary.is_constant());
assert_eq!(summary.range(), 0.0);
assert_eq!(summary.change, 0.0);
}
#[test]
fn the_summaries_cover_every_label_in_order() {
let mut trace = Trace::new();
trace.push(Record::observation(1.0, "b", 1.0));
trace.push(Record::observation(1.0, "a", 2.0));
trace.push(Record::observation(2.0, "b", 3.0));
let summaries = trace.summaries();
assert_eq!(summaries.len(), 2);
assert_eq!(summaries[0].label, "a");
assert_eq!(summaries[1].label, "b");
assert_eq!(summaries[1].count, 2);
}
#[test]
fn the_longest_series_is_reported() {
let mut trace = Trace::new();
for index in 0..5 {
trace.push(Record::observation(f64::from(index), "x", 1.0));
}
for index in 0..2 {
trace.push(Record::observation(f64::from(index), "y", 1.0));
}
assert_eq!(trace.longest_series(), 5);
}
#[test]
fn a_non_finite_record_is_refused() {
let mut trace = Trace::new();
assert!(!trace.push(Record::observation(f64::NAN, "x", 1.0)));
assert!(!trace.push(Record::observation(1.0, "x", f64::INFINITY)));
assert!(!trace.push(Record::observation(1.0, "", 1.0)));
assert!(trace.is_empty());
// A finite record is accepted.
assert!(trace.push(Record::observation(1.0, "x", 1.0)));
assert_eq!(trace.len(), 1);
assert!(trace.is_stable());
}
#[test]
fn clearing_empties_the_trace() {
let mut trace = Trace::new();
trace.push(Record::observation(1.0, "x", 1.0));
trace.push(Record::observation(2.0, "x", 2.0));
assert_eq!(trace.clear(), 2);
assert!(trace.is_empty());
}
#[test]
fn the_recorder_collects_records_of_each_kind() {
let mut recorder = Recorder::new();
assert!(recorder.observe(1.0, "range", 100.0));
assert!(recorder.note(2.0, "eclipse", 1.0));
assert!(recorder.warn(3.0, "temperature", 90.0));
assert_eq!(recorder.len(), 3);
assert!(!recorder.is_empty());
let trace = recorder.trace();
assert_eq!(trace.of_kind(RecordKind::Observation).len(), 1);
assert_eq!(trace.of_kind(RecordKind::State).len(), 1);
assert_eq!(trace.of_kind(RecordKind::Warning).len(), 1);
}
#[test]
fn the_recorder_counts_what_it_refused() {
let mut recorder = Recorder::new();
assert!(!recorder.observe(f64::NAN, "x", 1.0));
assert!(!recorder.observe(1.0, "x", f64::NAN));
assert!(recorder.observe(1.0, "x", 1.0));
assert_eq!(recorder.refused(), 2);
assert_eq!(recorder.len(), 1);
}
#[test]
fn taking_the_trace_leaves_the_recorder_empty() {
let mut recorder = Recorder::new();
recorder.observe(1.0, "x", 1.0);
recorder.observe(2.0, "x", 2.0);
let trace = recorder.take_trace();
assert_eq!(trace.len(), 2);
assert!(recorder.is_empty());
// The refused count belongs to the recorder and is not reset.
recorder.observe(f64::NAN, "x", 1.0);
assert_eq!(recorder.refused(), 1);
}
#[test]
fn the_recorder_can_be_cleared_without_taking() {
let mut recorder = Recorder::new();
recorder.observe(1.0, "x", 1.0);
assert_eq!(recorder.clear(), 1);
assert!(recorder.is_empty());
assert_eq!(recorder.trace().len(), 0);
}
#[test]
fn a_trace_can_be_built_from_records() {
let records = vec![
Record::observation(1.0, "a", 1.0),
Record::observation(2.0, "b", 2.0),
];
let trace = Trace::from_records(records);
assert_eq!(trace.len(), 2);
assert_eq!(trace.value_of("b"), Some(2.0));
}
#[test]
fn a_record_displays_its_shape() {
let record = Record::warning(3.5, "temperature", 90.0);
let text = record.to_string();
assert!(text.contains("warning"), "{text}");
assert!(text.contains("temperature"), "{text}");
assert!(text.contains("90"), "{text}");
}
#[test]
fn the_record_kinds_report_themselves() {
assert_eq!(RecordKind::Observation.name(), "observation");
assert_eq!(RecordKind::State.name(), "state");
assert_eq!(RecordKind::Warning.name(), "warning");
assert!(RecordKind::Warning.is_warning());
assert!(!RecordKind::State.is_warning());
assert_eq!(RecordKind::State.to_string(), "state");
}
#[test]
fn a_record_reports_whether_it_is_usable() {
assert!(Record::observation(1.0, "x", 1.0).is_valid());
assert!(!Record::observation(f64::NAN, "x", 1.0).is_valid());
assert!(!Record::observation(1.0, "x", f64::NAN).is_valid());
assert!(!Record::observation(1.0, "", 1.0).is_valid());
}
#[test]
fn the_trace_default_is_empty() {
let trace = Trace::default();
assert!(trace.is_empty());
}
#[test]
fn the_recorder_default_is_empty() {
let recorder = Recorder::default();
assert!(recorder.is_empty());
assert_eq!(recorder.refused(), 0);
}