1use std::collections::HashSet;
14
15use serde::{Deserialize, Serialize};
16
17pub const MAX_INPUT_BYTES: u32 = 256 * 1024;
19pub const MAX_OUTPUT_BYTES: u32 = 16 * 1024;
21pub const MAX_FRAME_BYTES: u32 = 64 * 1024;
23
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct Declaration {
26 pub arena_id: String,
29 pub arena_version: String,
31 pub changelog: Vec<ChangelogEntry>,
33 pub payload_schema_version: u32,
35 pub entrants: Entrants,
36 pub inputs: Vec<ValueDecl>,
38 pub outputs: Vec<ValueDecl>,
41 pub no_action: String,
44 pub control_rate: u32,
46 pub budgets: Budgets,
47 pub max_turns: u32,
49 pub wire: WireSizes,
50 pub stats: Vec<StatDecl>,
51 pub series: Vec<SeriesDecl>,
52 pub events: Vec<EventDecl>,
53 pub shaping_reward: String,
55 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#[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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
122#[serde(rename_all = "snake_case")]
123pub enum Bounds {
124 Uniform { min: f32, max: f32 },
126 PerElement(Vec<(f32, f32)>),
128}
129
130impl Bounds {
131 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#[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#[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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
212pub struct Budgets {
213 pub turn_fuel: u64,
214 pub init_fuel: u64,
215}
216
217#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub enum StatKind {
229 Count,
230 Duration,
232 Ratio,
234 Measure,
236 Score,
237}
238
239#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(rename_all = "snake_case")]
273pub enum SeriesScope {
274 PerSeat,
275 Session,
276}
277
278#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
290pub struct EventDecl {
291 pub key: String,
292 pub label: String,
293 pub doc: String,
294}
295
296#[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 pub fn input_payload_bytes(&self) -> usize {
329 crate::payload::encoded_len(&self.inputs)
330 }
331
332 pub fn output_payload_bytes(&self) -> usize {
334 crate::payload::encoded_len(&self.outputs)
335 }
336
337 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
571pub 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}