Skip to main content

botstadia_interface/
declaration.rs

1//! What an arena says about itself.
2//!
3//! The declaration is the complete, mechanical contract between an arena and
4//! everything around it: the host validates outputs against it, the site draws
5//! the arena's Interface section and its metrics from it, and the CLI, the
6//! policy shell and `botstadia-train` generate their code from it. Nothing
7//! around an arena needs per-arena code.
8//!
9//! An arena writes it once, in its declaration crate, with the builders here.
10//! [`Declaration::problems`] lists everything wrong with one, and an arena's
11//! tests require it to be empty.
12
13use std::collections::HashSet;
14
15use serde::{Deserialize, Serialize};
16
17/// Platform ceiling on one seat's input, in bytes.
18pub const MAX_INPUT_BYTES: u32 = 256 * 1024;
19/// Platform ceiling on one seat's output, in bytes.
20pub const MAX_OUTPUT_BYTES: u32 = 16 * 1024;
21/// Platform ceiling on one replay frame, in bytes.
22pub const MAX_FRAME_BYTES: u32 = 64 * 1024;
23
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct Declaration {
26    /// The arena's slug, e.g. `boxing`. With the payload schema version, it
27    /// names the ladder.
28    pub arena_id: String,
29    /// Semver. Bumped with every engine change.
30    pub arena_version: String,
31    /// What changed in each version, newest first.
32    pub changelog: Vec<ChangelogEntry>,
33    /// Bumped when inputs or outputs change. A new schema starts a new ladder.
34    pub payload_schema_version: u32,
35    pub entrants: Entrants,
36    /// Every observation value, in payload order.
37    pub inputs: Vec<ValueDecl>,
38    /// Every action value, in payload order. Their bounds are the only
39    /// validity check on an output.
40    pub outputs: Vec<ValueDecl>,
41    /// What a seat does with no valid action, in plain words. The engine
42    /// implements it; it's never inferred from the bounds.
43    pub no_action: String,
44    /// Turns per second.
45    pub control_rate: u32,
46    pub budgets: Budgets,
47    /// Reaching it ends the session with a real result.
48    pub max_turns: u32,
49    pub wire: WireSizes,
50    pub stats: Vec<StatDecl>,
51    pub series: Vec<SeriesDecl>,
52    pub events: Vec<EventDecl>,
53    /// The training-only shaping reward, in words. It never affects scoring.
54    pub shaping_reward: String,
55    /// The oldest `botstadia-train` that can train on this version.
56    pub min_trainer_version: String,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct ChangelogEntry {
61    pub version: String,
62    pub changes: String,
63}
64
65/// How many bots a session seats.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum Entrants {
69    Exact(u32),
70    Range { min: u32, max: u32 },
71}
72
73impl Entrants {
74    pub fn accepts(self, seats: u32) -> bool {
75        match self {
76            Self::Exact(count) => seats == count,
77            Self::Range { min, max } => (min..=max).contains(&seats),
78        }
79    }
80
81    pub fn max(self) -> u32 {
82        match self {
83            Self::Exact(count) => count,
84            Self::Range { max, .. } => max,
85        }
86    }
87
88    pub fn min(self) -> u32 {
89        match self {
90            Self::Exact(count) => count,
91            Self::Range { min, .. } => min,
92        }
93    }
94}
95
96/// A value's element type.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum Dtype {
100    F32,
101    U8,
102}
103
104impl Dtype {
105    pub fn name(self) -> &'static str {
106        match self {
107            Self::F32 => "f32",
108            Self::U8 => "u8",
109        }
110    }
111
112    pub fn bytes(self) -> usize {
113        match self {
114            Self::F32 => 4,
115            Self::U8 => 1,
116        }
117    }
118}
119
120/// The allowed range of each element of a value, inclusive.
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum Bounds {
124    /// Every element shares one range.
125    Uniform { min: f32, max: f32 },
126    /// One range per element, in row-major order.
127    PerElement(Vec<(f32, f32)>),
128}
129
130impl Bounds {
131    /// The range of element `index`.
132    pub fn range(&self, index: usize) -> Option<(f32, f32)> {
133        match self {
134            Self::Uniform { min, max } => Some((*min, *max)),
135            Self::PerElement(ranges) => ranges.get(index).copied(),
136        }
137    }
138}
139
140/// A named run of elements inside a value, with its unit.
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
142pub struct SliceDecl {
143    pub name: String,
144    pub count: u32,
145    pub unit: String,
146    pub doc: String,
147}
148
149/// One observation or action.
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151pub struct ValueDecl {
152    pub name: String,
153    pub dtype: Dtype,
154    pub shape: Vec<u32>,
155    pub bounds: Bounds,
156    pub doc: String,
157    /// Named runs covering the value exactly, in order. Empty for a value
158    /// with no parts worth naming, such as a small image.
159    pub slices: Vec<SliceDecl>,
160}
161
162impl ValueDecl {
163    pub fn new(name: &str, dtype: Dtype, shape: &[u32], bounds: Bounds, doc: &str) -> Self {
164        Self {
165            name: name.into(),
166            dtype,
167            shape: shape.to_vec(),
168            bounds,
169            doc: doc.into(),
170            slices: Vec::new(),
171        }
172    }
173
174    /// A flat `f32` value built from its slices, one shared range.
175    pub fn f32_slices(name: &str, doc: &str, min: f32, max: f32, slices: Vec<SliceDecl>) -> Self {
176        let count = slices.iter().map(|slice| slice.count).sum();
177        Self {
178            name: name.into(),
179            dtype: Dtype::F32,
180            shape: vec![count],
181            bounds: Bounds::Uniform { min, max },
182            doc: doc.into(),
183            slices,
184        }
185    }
186
187    pub fn with_slices(mut self, slices: Vec<SliceDecl>) -> Self {
188        self.slices = slices;
189        self
190    }
191
192    pub fn elements(&self) -> usize {
193        self.shape.iter().map(|size| *size as usize).product()
194    }
195
196    pub fn bytes(&self) -> usize {
197        self.elements() * self.dtype.bytes()
198    }
199}
200
201pub fn slice(name: &str, count: u32, unit: &str, doc: &str) -> SliceDecl {
202    SliceDecl {
203        name: name.into(),
204        count,
205        unit: unit.into(),
206        doc: doc.into(),
207    }
208}
209
210/// The work budgets, in fuel.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
212pub struct Budgets {
213    pub turn_fuel: u64,
214    pub init_fuel: u64,
215}
216
217/// The arena's own maximum sizes, each under its platform ceiling.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219pub struct WireSizes {
220    pub max_input_bytes: u32,
221    pub max_output_bytes: u32,
222    pub max_frame_bytes: u32,
223}
224
225/// What a stat measures, which decides how the site words it.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub enum StatKind {
229    Count,
230    /// Seconds.
231    Duration,
232    /// 0 to 1, shown as a percentage.
233    Ratio,
234    /// A plain number with its unit.
235    Measure,
236    Score,
237}
238
239/// How a stat rolls up into a bot's form over its recent sessions.
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(rename_all = "snake_case")]
242pub enum Aggregate {
243    Mean,
244    Sum,
245    Min,
246    Max,
247}
248
249/// Whether more of a stat is better. Decides the best value in a form.
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "snake_case")]
252pub enum Better {
253    Higher,
254    Lower,
255    Neither,
256}
257
258/// One session stat, one value per seat at the end.
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260pub struct StatDecl {
261    pub key: String,
262    pub label: String,
263    pub unit: String,
264    pub kind: StatKind,
265    pub form: Aggregate,
266    pub better: Better,
267    pub doc: String,
268}
269
270/// Whose line a per-turn series draws.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(rename_all = "snake_case")]
273pub enum SeriesScope {
274    PerSeat,
275    Session,
276}
277
278/// One per-turn graph.
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280pub struct SeriesDecl {
281    pub key: String,
282    pub label: String,
283    pub unit: String,
284    pub scope: SeriesScope,
285    pub doc: String,
286}
287
288/// A moment worth marking on the timeline.
289#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
290pub struct EventDecl {
291    pub key: String,
292    pub label: String,
293    pub doc: String,
294}
295
296/// One thing wrong with a declaration.
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct Problem(pub String);
299
300impl std::fmt::Display for Problem {
301    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302        f.write_str(&self.0)
303    }
304}
305
306impl Declaration {
307    pub fn to_json(&self) -> Vec<u8> {
308        serde_json::to_vec_pretty(self).expect("a declaration always serialises")
309    }
310
311    pub fn from_json(bytes: &[u8]) -> Result<Self, serde_json::Error> {
312        serde_json::from_slice(bytes)
313    }
314
315    pub fn stat_index(&self, key: &str) -> Option<usize> {
316        self.stats.iter().position(|stat| stat.key == key)
317    }
318
319    pub fn series_index(&self, key: &str) -> Option<usize> {
320        self.series.iter().position(|series| series.key == key)
321    }
322
323    pub fn event_index(&self, key: &str) -> Option<usize> {
324        self.events.iter().position(|event| event.key == key)
325    }
326
327    /// The largest input payload the declaration allows.
328    pub fn input_payload_bytes(&self) -> usize {
329        crate::payload::encoded_len(&self.inputs)
330    }
331
332    /// The largest output payload the declaration allows.
333    pub fn output_payload_bytes(&self) -> usize {
334        crate::payload::encoded_len(&self.outputs)
335    }
336
337    /// Everything wrong with this declaration. Empty means it's complete.
338    pub fn problems(&self) -> Vec<Problem> {
339        let mut problems = Vec::new();
340        let mut problem = |text: String| problems.push(Problem(text));
341
342        if !is_slug(&self.arena_id) {
343            problem(format!(
344                "arena id `{}` must be lowercase letters, digits and dashes",
345                self.arena_id
346            ));
347        }
348        if !is_semver(&self.arena_version) {
349            problem(format!(
350                "arena version `{}` must be semver (major.minor.patch)",
351                self.arena_version
352            ));
353        }
354        if self.changelog.first().map(|entry| entry.version.as_str())
355            != Some(self.arena_version.as_str())
356        {
357            problem(format!(
358                "the changelog's newest entry must be version {}",
359                self.arena_version
360            ));
361        }
362        if !is_semver(&self.min_trainer_version) {
363            problem(format!(
364                "minimum trainer version `{}` must be semver",
365                self.min_trainer_version
366            ));
367        }
368        match self.entrants {
369            Entrants::Exact(0) => problem("entrants must be at least 1".into()),
370            Entrants::Range { min, max } if min == 0 || min > max => {
371                problem(format!(
372                    "entrants range {min}..={max} is empty or starts at 0"
373                ));
374            }
375            _ => {}
376        }
377        if self.control_rate == 0 {
378            problem("control rate must be at least 1 turn per second".into());
379        }
380        if self.max_turns == 0 {
381            problem("maximum turns must be at least 1".into());
382        }
383        if self.budgets.turn_fuel == 0 || self.budgets.init_fuel == 0 {
384            problem("turn and init budgets must both be set".into());
385        }
386        if self.no_action.trim().is_empty() {
387            problem("no-action must be declared".into());
388        }
389        if self.shaping_reward.trim().is_empty() {
390            problem("the shaping reward must be documented".into());
391        }
392
393        for (side, values) in [("input", &self.inputs), ("output", &self.outputs)] {
394            if values.is_empty() {
395                problem(format!("there must be at least one {side} value"));
396            }
397            unique(
398                values.iter().map(|value| value.name.as_str()),
399                &format!("{side} value"),
400                &mut problem,
401            );
402            for value in values {
403                value_problems(side, value, &mut problem);
404            }
405        }
406
407        let input = self.input_payload_bytes();
408        let output = self.output_payload_bytes();
409        let wire = self.wire;
410        if wire.max_input_bytes > MAX_INPUT_BYTES {
411            problem(format!(
412                "max input {} bytes is over the platform's {MAX_INPUT_BYTES}",
413                wire.max_input_bytes
414            ));
415        }
416        if wire.max_output_bytes > MAX_OUTPUT_BYTES {
417            problem(format!(
418                "max output {} bytes is over the platform's {MAX_OUTPUT_BYTES}",
419                wire.max_output_bytes
420            ));
421        }
422        if wire.max_frame_bytes > MAX_FRAME_BYTES {
423            problem(format!(
424                "max frame {} bytes is over the platform's {MAX_FRAME_BYTES}",
425                wire.max_frame_bytes
426            ));
427        }
428        if input > wire.max_input_bytes as usize {
429            problem(format!(
430                "the inputs encode to {input} bytes, over the declared max of {}",
431                wire.max_input_bytes
432            ));
433        }
434        if output > wire.max_output_bytes as usize {
435            problem(format!(
436                "the outputs encode to {output} bytes, over the declared max of {}",
437                wire.max_output_bytes
438            ));
439        }
440
441        unique(
442            self.stats.iter().map(|stat| stat.key.as_str()),
443            "stat",
444            &mut problem,
445        );
446        unique(
447            self.series.iter().map(|series| series.key.as_str()),
448            "series",
449            &mut problem,
450        );
451        unique(
452            self.events.iter().map(|event| event.key.as_str()),
453            "event",
454            &mut problem,
455        );
456        for stat in &self.stats {
457            documented(
458                &format!("stat `{}`", stat.key),
459                &[&stat.label, &stat.doc],
460                &mut problem,
461            );
462        }
463        for series in &self.series {
464            documented(
465                &format!("series `{}`", series.key),
466                &[&series.label, &series.doc],
467                &mut problem,
468            );
469        }
470        for event in &self.events {
471            documented(
472                &format!("event `{}`", event.key),
473                &[&event.label, &event.doc],
474                &mut problem,
475            );
476        }
477        problems
478    }
479}
480
481fn value_problems(side: &str, value: &ValueDecl, problem: &mut impl FnMut(String)) {
482    let name = format!("{side} `{}`", value.name);
483    if !is_identifier(&value.name) {
484        problem(format!("{name}: names must be lowercase snake_case"));
485    }
486    if value.doc.trim().is_empty() {
487        problem(format!("{name} has no documentation"));
488    }
489    if value.shape.is_empty() || value.shape.contains(&0) {
490        problem(format!("{name} has an empty shape"));
491    }
492    let elements = value.elements();
493    match &value.bounds {
494        Bounds::Uniform { min, max } => bound_problems(&name, *min, *max, problem),
495        Bounds::PerElement(ranges) => {
496            if ranges.len() != elements {
497                problem(format!(
498                    "{name} has {} ranges for {elements} elements",
499                    ranges.len()
500                ));
501            }
502            for (min, max) in ranges {
503                bound_problems(&name, *min, *max, problem);
504            }
505        }
506    }
507    if value.dtype == Dtype::U8 {
508        let fits = |bound: f32| (0.0..=255.0).contains(&bound) && bound.fract() == 0.0;
509        let ok = (0..elements).all(|index| {
510            value
511                .bounds
512                .range(index)
513                .is_some_and(|(min, max)| fits(min) && fits(max))
514        });
515        if !ok {
516            problem(format!(
517                "{name} is u8, so its bounds must be whole numbers from 0 to 255"
518            ));
519        }
520    }
521    if !value.slices.is_empty() {
522        let covered: u64 = value
523            .slices
524            .iter()
525            .map(|slice| u64::from(slice.count))
526            .sum();
527        if covered != elements as u64 {
528            problem(format!(
529                "{name}'s slices cover {covered} of its {elements} elements"
530            ));
531        }
532        unique(
533            value.slices.iter().map(|slice| slice.name.as_str()),
534            &format!("{name} slice"),
535            problem,
536        );
537        for slice in &value.slices {
538            if slice.count == 0 {
539                problem(format!("{name} slice `{}` is empty", slice.name));
540            }
541            documented(
542                &format!("{name} slice `{}`", slice.name),
543                &[&slice.doc, &slice.unit],
544                problem,
545            );
546        }
547    }
548}
549
550fn bound_problems(name: &str, min: f32, max: f32, problem: &mut impl FnMut(String)) {
551    if !min.is_finite() || !max.is_finite() || min > max {
552        problem(format!("{name} has an invalid range {min}..={max}"));
553    }
554}
555
556fn documented(what: &str, texts: &[&str], problem: &mut impl FnMut(String)) {
557    if texts.iter().any(|text| text.trim().is_empty()) {
558        problem(format!("{what} isn't fully documented"));
559    }
560}
561
562fn unique<'a>(names: impl Iterator<Item = &'a str>, what: &str, problem: &mut impl FnMut(String)) {
563    let mut seen = HashSet::new();
564    for name in names {
565        if !seen.insert(name) {
566            problem(format!("{what} `{name}` is declared twice"));
567        }
568    }
569}
570
571/// Lowercase letters, digits and single dashes, starting with a letter.
572pub fn is_slug(text: &str) -> bool {
573    !text.is_empty()
574        && text.len() <= 32
575        && text.starts_with(|c: char| c.is_ascii_lowercase())
576        && !text.ends_with('-')
577        && !text.contains("--")
578        && text
579            .chars()
580            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
581}
582
583fn is_identifier(text: &str) -> bool {
584    text.starts_with(|c: char| c.is_ascii_lowercase())
585        && text
586            .chars()
587            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
588}
589
590fn is_semver(text: &str) -> bool {
591    let parts: Vec<&str> = text.split('.').collect();
592    parts.len() == 3
593        && parts
594            .iter()
595            .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
596}
597
598#[cfg(test)]
599pub(crate) mod tests {
600    use super::*;
601
602    pub fn sample() -> Declaration {
603        Declaration {
604            arena_id: "sample".into(),
605            arena_version: "0.1.0".into(),
606            changelog: vec![ChangelogEntry {
607                version: "0.1.0".into(),
608                changes: "First version.".into(),
609            }],
610            payload_schema_version: 1,
611            entrants: Entrants::Exact(2),
612            inputs: vec![ValueDecl::f32_slices(
613                "body",
614                "Your own body.",
615                -10.0,
616                10.0,
617                vec![
618                    slice("position", 3, "m", "Where you are."),
619                    slice("velocity", 3, "m/s", "How you're moving."),
620                ],
621            )],
622            outputs: vec![
623                ValueDecl::new(
624                    "thrust",
625                    Dtype::F32,
626                    &[2],
627                    Bounds::Uniform {
628                        min: -1.0,
629                        max: 1.0,
630                    },
631                    "Push left and right.",
632                )
633                .with_slices(vec![slice("thrust", 2, "N", "Each side's push.")]),
634            ],
635            no_action: "no thrust".into(),
636            control_rate: 50,
637            budgets: Budgets {
638                turn_fuel: 1_000_000,
639                init_fuel: 16_000_000,
640            },
641            max_turns: 500,
642            wire: WireSizes {
643                max_input_bytes: 1024,
644                max_output_bytes: 256,
645                max_frame_bytes: 4096,
646            },
647            stats: vec![StatDecl {
648                key: "hits".into(),
649                label: "Hits".into(),
650                unit: "hits".into(),
651                kind: StatKind::Count,
652                form: Aggregate::Mean,
653                better: Better::Higher,
654                doc: "Clean hits landed.".into(),
655            }],
656            series: vec![SeriesDecl {
657                key: "distance".into(),
658                label: "Distance".into(),
659                unit: "m".into(),
660                scope: SeriesScope::Session,
661                doc: "How far apart they are.".into(),
662            }],
663            events: vec![EventDecl {
664                key: "hit".into(),
665                label: "Hit".into(),
666                doc: "A clean hit.".into(),
667            }],
668            shaping_reward: "Closing distance, plus hits.".into(),
669            min_trainer_version: "0.1.0".into(),
670        }
671    }
672
673    #[test]
674    fn the_sample_is_complete() {
675        assert_eq!(sample().problems(), Vec::<Problem>::new());
676    }
677
678    #[test]
679    fn undocumented_and_uncovered_values_are_problems() {
680        let mut declaration = sample();
681        declaration.inputs[0].doc.clear();
682        declaration.inputs[0].slices.pop();
683        declaration.events[0].doc.clear();
684        let problems: Vec<String> = declaration
685            .problems()
686            .into_iter()
687            .map(|problem| problem.0)
688            .collect();
689        assert!(
690            problems
691                .iter()
692                .any(|text| text.contains("no documentation"))
693        );
694        assert!(
695            problems
696                .iter()
697                .any(|text| text.contains("cover 3 of its 6"))
698        );
699        assert!(problems.iter().any(|text| text.contains("event `hit`")));
700    }
701
702    #[test]
703    fn wire_sizes_must_fit_the_ceilings_and_the_values() {
704        let mut declaration = sample();
705        declaration.wire.max_frame_bytes = MAX_FRAME_BYTES + 1;
706        declaration.wire.max_input_bytes = 4;
707        let problems = declaration.problems();
708        assert!(
709            problems
710                .iter()
711                .any(|problem| problem.0.contains("max frame"))
712        );
713        assert!(
714            problems
715                .iter()
716                .any(|problem| problem.0.contains("the inputs encode"))
717        );
718    }
719
720    #[test]
721    fn names_are_unique_and_slugs_are_plain() {
722        let mut declaration = sample();
723        declaration.arena_id = "Bad Slug".into();
724        declaration.stats.push(declaration.stats[0].clone());
725        let problems = declaration.problems();
726        assert!(
727            problems
728                .iter()
729                .any(|problem| problem.0.contains("arena id"))
730        );
731        assert!(
732            problems
733                .iter()
734                .any(|problem| problem.0.contains("declared twice"))
735        );
736        assert!(is_slug("dance-off") && !is_slug("dance--off") && !is_slug("-x"));
737    }
738
739    #[test]
740    fn json_round_trips() {
741        let declaration = sample();
742        assert_eq!(
743            Declaration::from_json(&declaration.to_json()).unwrap(),
744            declaration
745        );
746    }
747}