botstadia_interface/policy.rs
1//! The policy shell's ONNX contract, version 1: how a trained policy's model
2//! must look so one generic program runs it in every arena.
3//!
4//! - **Inputs:** one per declared observation, named exactly as declared,
5//! shape `[1, *shape]`, `f32`. A `u8` value is fed as `f32 / 255`. A policy
6//! may use a subset of the observations.
7//! - **Memory:** an optional `state_in: f32[1, S]` → `state_out: f32[1, S]`
8//! pair, `S` at most [`MAX_STATE`]. Zeros at the start; each turn's
9//! `state_out` is the next turn's `state_in`.
10//! - **Continuous actions** (declared `f32`): one output per declared action,
11//! named as declared, shape `[1, *shape]`, in `[-1, 1]`. The shell maps each
12//! element with [`to_bounds`]: clamp to `[-1, 1]` (NaN is 0, the midpoint),
13//! then affinely onto the element's declared bounds.
14//! - **Discrete actions** (declared `u8`): one output per declared action,
15//! named as declared, of logits shaped `[1, *shape, n]`, where every element
16//! shares one range `[min, max]` and `n = max - min + 1`. The shell takes the
17//! argmax of each element's logits ([`argmax`]) and adds `min`.
18//! - **Metadata:** [`META_CONTRACT`], [`META_ARENA`], [`META_PAYLOAD_SCHEMA`],
19//! [`META_TRAIN_VERSION`] and [`META_OPSET`], checked against the manifest.
20//! - An input named [`NOISE`] is reserved.
21//!
22//! `botstadia-train` implements the same map; the shared golden file
23//! (`policy-golden.json`, beside this crate's tests) pins both.
24
25/// The golden cases pinning [`to_bounds`], [`feed_u8`] and [`argmax`], which
26/// `botstadia-train` checks its own implementation against.
27pub const GOLDEN: &str = include_str!("../tests/policy-golden.json");
28
29/// This contract's version, as the `botstadia.contract` metadata states it.
30pub const CONTRACT_VERSION: u32 = 1;
31
32pub const META_CONTRACT: &str = "botstadia.contract";
33pub const META_ARENA: &str = "botstadia.arena";
34pub const META_PAYLOAD_SCHEMA: &str = "botstadia.payload_schema";
35pub const META_TRAIN_VERSION: &str = "botstadia.train_version";
36pub const META_OPSET: &str = "botstadia.opset";
37
38/// The memory pair's names.
39pub const STATE_IN: &str = "state_in";
40pub const STATE_OUT: &str = "state_out";
41/// Reserved for sampled actions later.
42pub const NOISE: &str = "noise";
43/// The largest memory, in floats.
44pub const MAX_STATE: u32 = 1024;
45/// The opset the export helper writes.
46pub const EXPORT_OPSET: i64 = 18;
47
48/// One element of a continuous action: a policy output in `[-1, 1]` mapped
49/// onto the declared `[min, max]`. Out-of-range outputs are clamped and NaN
50/// is the midpoint, so the result is always valid.
51pub fn to_bounds(value: f32, min: f32, max: f32) -> f32 {
52 let unit = if value.is_nan() {
53 0.0
54 } else {
55 value.clamp(-1.0, 1.0)
56 };
57 let mapped = min + (unit + 1.0) * 0.5 * (max - min);
58 // Rounding can step a hair outside the bounds; the host rejects that.
59 mapped.clamp(min, max)
60}
61
62/// The inverse of [`to_bounds`], for training: a declared value as the
63/// policy's `[-1, 1]`.
64pub fn from_bounds(value: f32, min: f32, max: f32) -> f32 {
65 if max <= min {
66 return 0.0;
67 }
68 ((value - min) / (max - min) * 2.0 - 1.0).clamp(-1.0, 1.0)
69}
70
71/// An observation element as the policy sees it.
72pub fn feed_u8(value: u8) -> f32 {
73 f32::from(value) / 255.0
74}
75
76/// The index of the largest logit; NaN never wins, and ties go to the first.
77pub fn argmax(logits: &[f32]) -> usize {
78 let mut best = 0;
79 let mut best_value = f32::NEG_INFINITY;
80 for (index, value) in logits.iter().enumerate() {
81 if *value > best_value {
82 best = index;
83 best_value = *value;
84 }
85 }
86 best
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 /// The golden cases `botstadia-train` checks too.
94 #[derive(serde::Deserialize)]
95 struct Golden {
96 #[serde(rename = "_doc")]
97 _doc: String,
98 to_bounds: Vec<(f32, f32, f32, f32)>,
99 feed_u8: Vec<(u8, f32)>,
100 argmax: Vec<(Vec<f32>, usize)>,
101 }
102
103 #[test]
104 fn matches_the_shared_golden_file() {
105 let golden: Golden = serde_json::from_str(GOLDEN).unwrap();
106 for (value, min, max, expected) in golden.to_bounds {
107 assert_eq!(
108 to_bounds(value, min, max),
109 expected,
110 "{value} in {min}..{max}"
111 );
112 }
113 for (value, expected) in golden.feed_u8 {
114 assert_eq!(feed_u8(value), expected);
115 }
116 for (logits, expected) in golden.argmax {
117 assert_eq!(argmax(&logits), expected);
118 }
119 }
120
121 #[test]
122 fn nan_is_the_midpoint_and_the_map_inverts() {
123 assert_eq!(to_bounds(f32::NAN, -2.0, 4.0), 1.0);
124 for value in [-1.0, -0.25, 0.0, 0.6, 1.0] {
125 let there = to_bounds(value, -2.5, 2.88);
126 assert!((from_bounds(there, -2.5, 2.88) - value).abs() < 1e-6);
127 }
128 }
129}