use std::collections::BTreeMap;
use eredu_core::{
ObservationSet, ObservationValue, RealtimeOutputFrame, TensorObservation, TensorObservationData,
};
use eredu_nn::Tensor;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EvaluationEvidence {
pub format_version: u32,
pub kind: String,
pub provenance: BTreeMap<String, String>,
pub observations: ObservationSet,
}
impl EvaluationEvidence {
pub fn new(kind: impl Into<String>, observations: ObservationSet) -> Self {
Self {
format_version: 1,
kind: kind.into(),
provenance: BTreeMap::new(),
observations,
}
}
pub fn with_provenance(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.provenance.insert(key.into(), value.into());
self
}
}
pub fn observe_f32_tensor<T: Tensor>(
tensor: &T,
context: &T::Context,
) -> Result<TensorObservation, EvidenceError> {
let shape = observation_shape(tensor.shape())?;
let values = tensor.to_f32_vec(context)?;
TensorObservation::new(shape, TensorObservationData::F32(values)).map_err(Into::into)
}
pub fn observe_i32_tensor<T: Tensor>(
tensor: &T,
context: &T::Context,
) -> Result<TensorObservation, EvidenceError> {
let shape = observation_shape(tensor.shape())?;
let values = tensor
.to_i32_vec(context)?
.into_iter()
.map(i64::from)
.collect();
TensorObservation::new(shape, TensorObservationData::I64(values)).map_err(Into::into)
}
fn observation_shape(shape: &[i32]) -> Result<Vec<usize>, EvidenceError> {
shape
.iter()
.map(|dimension| {
usize::try_from(*dimension).map_err(|_| EvidenceError::NegativeDimension(*dimension))
})
.collect()
}
pub fn observe_realtime_frame(
frame: &RealtimeOutputFrame,
) -> Result<ObservationSet, EvidenceError> {
let mut observations = ObservationSet::new();
insert_realtime_tokens(
&mut observations,
"tokens.text",
frame.batch(),
frame.text_tokens(),
)?;
insert_realtime_tokens(
&mut observations,
"tokens.audio_decisions",
frame.batch(),
frame.decision_audio_tokens(),
)?;
insert_realtime_tokens(
&mut observations,
"tokens.audio_sampled",
frame.batch(),
frame.sampled_audio_tokens(),
)?;
if let Some(tokens) = frame.output_audio_tokens() {
insert_realtime_tokens(
&mut observations,
"tokens.audio_output",
frame.batch(),
tokens,
)?;
}
for diagnostic in frame.diagnostics() {
observations.insert(
format!("decisions.{}.logits", diagnostic.prediction()),
ObservationValue::Tensor(diagnostic.tensor().clone()),
)?;
}
Ok(observations)
}
fn insert_realtime_tokens(
observations: &mut ObservationSet,
path: &str,
batch: usize,
tokens: &[i32],
) -> Result<(), EvidenceError> {
if batch == 0 || !tokens.len().is_multiple_of(batch) {
return Err(EvidenceError::RealtimeTokenShape {
path: path.into(),
batch,
values: tokens.len(),
});
}
observations.insert(
path,
ObservationValue::Tensor(TensorObservation::new(
vec![batch, tokens.len() / batch],
TensorObservationData::I64(tokens.iter().copied().map(i64::from).collect()),
)?),
)?;
Ok(())
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LatencySummary {
pub samples: usize,
pub mean_ms: f64,
pub p50_ms: f64,
pub p95_ms: f64,
pub max_ms: f64,
pub deadline_ms: Option<f64>,
pub deadline_misses: usize,
}
pub fn summarize_latencies(
samples_ms: &[f64],
deadline_ms: Option<f64>,
) -> Result<LatencySummary, EvidenceError> {
if samples_ms.is_empty() {
return Err(EvidenceError::EmptyLatencies);
}
if samples_ms
.iter()
.any(|value| !value.is_finite() || *value < 0.0)
{
return Err(EvidenceError::InvalidLatency);
}
if deadline_ms.is_some_and(|value| !value.is_finite() || value < 0.0) {
return Err(EvidenceError::InvalidDeadline);
}
let mut ordered = samples_ms.to_vec();
ordered.sort_by(f64::total_cmp);
let percentile = |fraction: f64| {
let index = ((ordered.len() - 1) as f64 * fraction).ceil() as usize;
ordered[index]
};
Ok(LatencySummary {
samples: ordered.len(),
mean_ms: ordered.iter().sum::<f64>() / ordered.len() as f64,
p50_ms: percentile(0.50),
p95_ms: percentile(0.95),
max_ms: *ordered.last().expect("samples are nonempty"),
deadline_ms,
deadline_misses: deadline_ms.map_or(0, |deadline| {
ordered.iter().filter(|value| **value > deadline).count()
}),
})
}
#[derive(Debug, thiserror::Error)]
pub enum EvidenceError {
#[error(transparent)]
Tensor(#[from] eredu_nn::Error),
#[error(transparent)]
Observation(#[from] eredu_core::ObservationError),
#[error("observed tensor has negative dimension {0}")]
NegativeDimension(i32),
#[error("realtime observation {path:?} has {values} values for batch {batch}")]
RealtimeTokenShape {
path: String,
batch: usize,
values: usize,
},
#[error("latency summary requires at least one sample")]
EmptyLatencies,
#[error("latency samples must be finite and nonnegative")]
InvalidLatency,
#[error("latency deadline must be finite and nonnegative")]
InvalidDeadline,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn latency_summary_is_deterministic() {
let summary = summarize_latencies(&[4.0, 1.0, 3.0, 2.0], Some(2.5)).unwrap();
assert_eq!(summary.mean_ms, 2.5);
assert_eq!(summary.p50_ms, 3.0);
assert_eq!(summary.p95_ms, 4.0);
assert_eq!(summary.deadline_misses, 2);
}
#[test]
fn realtime_frames_use_the_general_observation_schema() {
let diagnostic =
eredu_core::RealtimeDecisionDiagnostics::new(0, vec![1, 3], vec![0.0, 2.0, 1.0])
.unwrap();
let frame = RealtimeOutputFrame::new(
1,
vec![7],
vec![8, 9],
vec![8],
Some(vec![6]),
vec![diagnostic],
);
let observations = observe_realtime_frame(&frame).unwrap();
assert!(observations.get("tokens.text").is_some());
assert!(observations.get("decisions.0.logits").is_some());
}
}