use std::collections::HashSet;
use serde::{Deserialize, Serialize};
pub const MAX_INPUT_BYTES: u32 = 256 * 1024;
pub const MAX_OUTPUT_BYTES: u32 = 16 * 1024;
pub const MAX_FRAME_BYTES: u32 = 64 * 1024;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Declaration {
pub arena_id: String,
pub arena_version: String,
pub changelog: Vec<ChangelogEntry>,
pub payload_schema_version: u32,
pub entrants: Entrants,
pub inputs: Vec<ValueDecl>,
pub outputs: Vec<ValueDecl>,
pub no_action: String,
pub control_rate: u32,
pub budgets: Budgets,
pub max_turns: u32,
pub wire: WireSizes,
pub stats: Vec<StatDecl>,
pub series: Vec<SeriesDecl>,
pub events: Vec<EventDecl>,
pub shaping_reward: String,
pub min_trainer_version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChangelogEntry {
pub version: String,
pub changes: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Entrants {
Exact(u32),
Range { min: u32, max: u32 },
}
impl Entrants {
pub fn accepts(self, seats: u32) -> bool {
match self {
Self::Exact(count) => seats == count,
Self::Range { min, max } => (min..=max).contains(&seats),
}
}
pub fn max(self) -> u32 {
match self {
Self::Exact(count) => count,
Self::Range { max, .. } => max,
}
}
pub fn min(self) -> u32 {
match self {
Self::Exact(count) => count,
Self::Range { min, .. } => min,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Dtype {
F32,
U8,
}
impl Dtype {
pub fn name(self) -> &'static str {
match self {
Self::F32 => "f32",
Self::U8 => "u8",
}
}
pub fn bytes(self) -> usize {
match self {
Self::F32 => 4,
Self::U8 => 1,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Bounds {
Uniform { min: f32, max: f32 },
PerElement(Vec<(f32, f32)>),
}
impl Bounds {
pub fn range(&self, index: usize) -> Option<(f32, f32)> {
match self {
Self::Uniform { min, max } => Some((*min, *max)),
Self::PerElement(ranges) => ranges.get(index).copied(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SliceDecl {
pub name: String,
pub count: u32,
pub unit: String,
pub doc: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ValueDecl {
pub name: String,
pub dtype: Dtype,
pub shape: Vec<u32>,
pub bounds: Bounds,
pub doc: String,
pub slices: Vec<SliceDecl>,
}
impl ValueDecl {
pub fn new(name: &str, dtype: Dtype, shape: &[u32], bounds: Bounds, doc: &str) -> Self {
Self {
name: name.into(),
dtype,
shape: shape.to_vec(),
bounds,
doc: doc.into(),
slices: Vec::new(),
}
}
pub fn f32_slices(name: &str, doc: &str, min: f32, max: f32, slices: Vec<SliceDecl>) -> Self {
let count = slices.iter().map(|slice| slice.count).sum();
Self {
name: name.into(),
dtype: Dtype::F32,
shape: vec![count],
bounds: Bounds::Uniform { min, max },
doc: doc.into(),
slices,
}
}
pub fn with_slices(mut self, slices: Vec<SliceDecl>) -> Self {
self.slices = slices;
self
}
pub fn elements(&self) -> usize {
self.shape.iter().map(|size| *size as usize).product()
}
pub fn bytes(&self) -> usize {
self.elements() * self.dtype.bytes()
}
}
pub fn slice(name: &str, count: u32, unit: &str, doc: &str) -> SliceDecl {
SliceDecl {
name: name.into(),
count,
unit: unit.into(),
doc: doc.into(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Budgets {
pub turn_fuel: u64,
pub init_fuel: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct WireSizes {
pub max_input_bytes: u32,
pub max_output_bytes: u32,
pub max_frame_bytes: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StatKind {
Count,
Duration,
Ratio,
Measure,
Score,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Aggregate {
Mean,
Sum,
Min,
Max,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Better {
Higher,
Lower,
Neither,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StatDecl {
pub key: String,
pub label: String,
pub unit: String,
pub kind: StatKind,
pub form: Aggregate,
pub better: Better,
pub doc: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SeriesScope {
PerSeat,
Session,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SeriesDecl {
pub key: String,
pub label: String,
pub unit: String,
pub scope: SeriesScope,
pub doc: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EventDecl {
pub key: String,
pub label: String,
pub doc: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Problem(pub String);
impl std::fmt::Display for Problem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl Declaration {
pub fn to_json(&self) -> Vec<u8> {
serde_json::to_vec_pretty(self).expect("a declaration always serialises")
}
pub fn from_json(bytes: &[u8]) -> Result<Self, serde_json::Error> {
serde_json::from_slice(bytes)
}
pub fn stat_index(&self, key: &str) -> Option<usize> {
self.stats.iter().position(|stat| stat.key == key)
}
pub fn series_index(&self, key: &str) -> Option<usize> {
self.series.iter().position(|series| series.key == key)
}
pub fn event_index(&self, key: &str) -> Option<usize> {
self.events.iter().position(|event| event.key == key)
}
pub fn input_payload_bytes(&self) -> usize {
crate::payload::encoded_len(&self.inputs)
}
pub fn output_payload_bytes(&self) -> usize {
crate::payload::encoded_len(&self.outputs)
}
pub fn problems(&self) -> Vec<Problem> {
let mut problems = Vec::new();
let mut problem = |text: String| problems.push(Problem(text));
if !is_slug(&self.arena_id) {
problem(format!(
"arena id `{}` must be lowercase letters, digits and dashes",
self.arena_id
));
}
if !is_semver(&self.arena_version) {
problem(format!(
"arena version `{}` must be semver (major.minor.patch)",
self.arena_version
));
}
if self.changelog.first().map(|entry| entry.version.as_str())
!= Some(self.arena_version.as_str())
{
problem(format!(
"the changelog's newest entry must be version {}",
self.arena_version
));
}
if !is_semver(&self.min_trainer_version) {
problem(format!(
"minimum trainer version `{}` must be semver",
self.min_trainer_version
));
}
match self.entrants {
Entrants::Exact(0) => problem("entrants must be at least 1".into()),
Entrants::Range { min, max } if min == 0 || min > max => {
problem(format!(
"entrants range {min}..={max} is empty or starts at 0"
));
}
_ => {}
}
if self.control_rate == 0 {
problem("control rate must be at least 1 turn per second".into());
}
if self.max_turns == 0 {
problem("maximum turns must be at least 1".into());
}
if self.budgets.turn_fuel == 0 || self.budgets.init_fuel == 0 {
problem("turn and init budgets must both be set".into());
}
if self.no_action.trim().is_empty() {
problem("no-action must be declared".into());
}
if self.shaping_reward.trim().is_empty() {
problem("the shaping reward must be documented".into());
}
for (side, values) in [("input", &self.inputs), ("output", &self.outputs)] {
if values.is_empty() {
problem(format!("there must be at least one {side} value"));
}
unique(
values.iter().map(|value| value.name.as_str()),
&format!("{side} value"),
&mut problem,
);
for value in values {
value_problems(side, value, &mut problem);
}
}
let input = self.input_payload_bytes();
let output = self.output_payload_bytes();
let wire = self.wire;
if wire.max_input_bytes > MAX_INPUT_BYTES {
problem(format!(
"max input {} bytes is over the platform's {MAX_INPUT_BYTES}",
wire.max_input_bytes
));
}
if wire.max_output_bytes > MAX_OUTPUT_BYTES {
problem(format!(
"max output {} bytes is over the platform's {MAX_OUTPUT_BYTES}",
wire.max_output_bytes
));
}
if wire.max_frame_bytes > MAX_FRAME_BYTES {
problem(format!(
"max frame {} bytes is over the platform's {MAX_FRAME_BYTES}",
wire.max_frame_bytes
));
}
if input > wire.max_input_bytes as usize {
problem(format!(
"the inputs encode to {input} bytes, over the declared max of {}",
wire.max_input_bytes
));
}
if output > wire.max_output_bytes as usize {
problem(format!(
"the outputs encode to {output} bytes, over the declared max of {}",
wire.max_output_bytes
));
}
unique(
self.stats.iter().map(|stat| stat.key.as_str()),
"stat",
&mut problem,
);
unique(
self.series.iter().map(|series| series.key.as_str()),
"series",
&mut problem,
);
unique(
self.events.iter().map(|event| event.key.as_str()),
"event",
&mut problem,
);
for stat in &self.stats {
documented(
&format!("stat `{}`", stat.key),
&[&stat.label, &stat.doc],
&mut problem,
);
}
for series in &self.series {
documented(
&format!("series `{}`", series.key),
&[&series.label, &series.doc],
&mut problem,
);
}
for event in &self.events {
documented(
&format!("event `{}`", event.key),
&[&event.label, &event.doc],
&mut problem,
);
}
problems
}
}
fn value_problems(side: &str, value: &ValueDecl, problem: &mut impl FnMut(String)) {
let name = format!("{side} `{}`", value.name);
if !is_identifier(&value.name) {
problem(format!("{name}: names must be lowercase snake_case"));
}
if value.doc.trim().is_empty() {
problem(format!("{name} has no documentation"));
}
if value.shape.is_empty() || value.shape.contains(&0) {
problem(format!("{name} has an empty shape"));
}
let elements = value.elements();
match &value.bounds {
Bounds::Uniform { min, max } => bound_problems(&name, *min, *max, problem),
Bounds::PerElement(ranges) => {
if ranges.len() != elements {
problem(format!(
"{name} has {} ranges for {elements} elements",
ranges.len()
));
}
for (min, max) in ranges {
bound_problems(&name, *min, *max, problem);
}
}
}
if value.dtype == Dtype::U8 {
let fits = |bound: f32| (0.0..=255.0).contains(&bound) && bound.fract() == 0.0;
let ok = (0..elements).all(|index| {
value
.bounds
.range(index)
.is_some_and(|(min, max)| fits(min) && fits(max))
});
if !ok {
problem(format!(
"{name} is u8, so its bounds must be whole numbers from 0 to 255"
));
}
}
if !value.slices.is_empty() {
let covered: u64 = value
.slices
.iter()
.map(|slice| u64::from(slice.count))
.sum();
if covered != elements as u64 {
problem(format!(
"{name}'s slices cover {covered} of its {elements} elements"
));
}
unique(
value.slices.iter().map(|slice| slice.name.as_str()),
&format!("{name} slice"),
problem,
);
for slice in &value.slices {
if slice.count == 0 {
problem(format!("{name} slice `{}` is empty", slice.name));
}
documented(
&format!("{name} slice `{}`", slice.name),
&[&slice.doc, &slice.unit],
problem,
);
}
}
}
fn bound_problems(name: &str, min: f32, max: f32, problem: &mut impl FnMut(String)) {
if !min.is_finite() || !max.is_finite() || min > max {
problem(format!("{name} has an invalid range {min}..={max}"));
}
}
fn documented(what: &str, texts: &[&str], problem: &mut impl FnMut(String)) {
if texts.iter().any(|text| text.trim().is_empty()) {
problem(format!("{what} isn't fully documented"));
}
}
fn unique<'a>(names: impl Iterator<Item = &'a str>, what: &str, problem: &mut impl FnMut(String)) {
let mut seen = HashSet::new();
for name in names {
if !seen.insert(name) {
problem(format!("{what} `{name}` is declared twice"));
}
}
}
pub fn is_slug(text: &str) -> bool {
!text.is_empty()
&& text.len() <= 32
&& text.starts_with(|c: char| c.is_ascii_lowercase())
&& !text.ends_with('-')
&& !text.contains("--")
&& text
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn is_identifier(text: &str) -> bool {
text.starts_with(|c: char| c.is_ascii_lowercase())
&& text
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
fn is_semver(text: &str) -> bool {
let parts: Vec<&str> = text.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub fn sample() -> Declaration {
Declaration {
arena_id: "sample".into(),
arena_version: "0.1.0".into(),
changelog: vec![ChangelogEntry {
version: "0.1.0".into(),
changes: "First version.".into(),
}],
payload_schema_version: 1,
entrants: Entrants::Exact(2),
inputs: vec![ValueDecl::f32_slices(
"body",
"Your own body.",
-10.0,
10.0,
vec![
slice("position", 3, "m", "Where you are."),
slice("velocity", 3, "m/s", "How you're moving."),
],
)],
outputs: vec![
ValueDecl::new(
"thrust",
Dtype::F32,
&[2],
Bounds::Uniform {
min: -1.0,
max: 1.0,
},
"Push left and right.",
)
.with_slices(vec![slice("thrust", 2, "N", "Each side's push.")]),
],
no_action: "no thrust".into(),
control_rate: 50,
budgets: Budgets {
turn_fuel: 1_000_000,
init_fuel: 16_000_000,
},
max_turns: 500,
wire: WireSizes {
max_input_bytes: 1024,
max_output_bytes: 256,
max_frame_bytes: 4096,
},
stats: vec![StatDecl {
key: "hits".into(),
label: "Hits".into(),
unit: "hits".into(),
kind: StatKind::Count,
form: Aggregate::Mean,
better: Better::Higher,
doc: "Clean hits landed.".into(),
}],
series: vec![SeriesDecl {
key: "distance".into(),
label: "Distance".into(),
unit: "m".into(),
scope: SeriesScope::Session,
doc: "How far apart they are.".into(),
}],
events: vec![EventDecl {
key: "hit".into(),
label: "Hit".into(),
doc: "A clean hit.".into(),
}],
shaping_reward: "Closing distance, plus hits.".into(),
min_trainer_version: "0.1.0".into(),
}
}
#[test]
fn the_sample_is_complete() {
assert_eq!(sample().problems(), Vec::<Problem>::new());
}
#[test]
fn undocumented_and_uncovered_values_are_problems() {
let mut declaration = sample();
declaration.inputs[0].doc.clear();
declaration.inputs[0].slices.pop();
declaration.events[0].doc.clear();
let problems: Vec<String> = declaration
.problems()
.into_iter()
.map(|problem| problem.0)
.collect();
assert!(
problems
.iter()
.any(|text| text.contains("no documentation"))
);
assert!(
problems
.iter()
.any(|text| text.contains("cover 3 of its 6"))
);
assert!(problems.iter().any(|text| text.contains("event `hit`")));
}
#[test]
fn wire_sizes_must_fit_the_ceilings_and_the_values() {
let mut declaration = sample();
declaration.wire.max_frame_bytes = MAX_FRAME_BYTES + 1;
declaration.wire.max_input_bytes = 4;
let problems = declaration.problems();
assert!(
problems
.iter()
.any(|problem| problem.0.contains("max frame"))
);
assert!(
problems
.iter()
.any(|problem| problem.0.contains("the inputs encode"))
);
}
#[test]
fn names_are_unique_and_slugs_are_plain() {
let mut declaration = sample();
declaration.arena_id = "Bad Slug".into();
declaration.stats.push(declaration.stats[0].clone());
let problems = declaration.problems();
assert!(
problems
.iter()
.any(|problem| problem.0.contains("arena id"))
);
assert!(
problems
.iter()
.any(|problem| problem.0.contains("declared twice"))
);
assert!(is_slug("dance-off") && !is_slug("dance--off") && !is_slug("-x"));
}
#[test]
fn json_round_trips() {
let declaration = sample();
assert_eq!(
Declaration::from_json(&declaration.to_json()).unwrap(),
declaration
);
}
}