nmbrs_runtime/readouts/readout.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! The [`Readout`] trait + the orthogonal [`Lod`] and
5//! [`ContentMode`] axes the engine renders against.
6//!
7//! See `docs/SRD/63_status_readouts.md` §1 for the design
8//! contract.
9
10use super::buf::ReadoutBuf;
11use super::context::ReadoutContext;
12use crate::lifecycle::SubjectKind;
13
14/// Level-of-Detail axis. See SRD-63 §3.
15///
16/// - [`Lod::Compact`] — smallest useful form for a trained
17/// operator (single glyph cluster, no labels).
18/// - [`Lod::Labeled`] — fits as much labelled info as the line
19/// width allows; wraps to additional lines if needed.
20/// - [`Lod::Expanded`] — maximum detail, multi-line, supports
21/// auxiliary visuals.
22///
23/// SRD-63 §3.3 invariant: lower LODs are strict information
24/// subsets of higher LODs (`fields(compact) ⊆ fields(labeled)
25/// ⊆ fields(expanded)`). This is a contract on each readout
26/// author, not a runtime check.
27#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
28pub enum Lod {
29 Compact,
30 Labeled,
31 Expanded,
32}
33
34/// Default LOD — `Labeled`, the canonical "show as much
35/// labelled info as fits" form. Matches the body parser's
36/// hard-coded fallback when a step has no `lod=` option,
37/// and the registry's `BakedBody::from_single` baseline.
38impl Default for Lod {
39 fn default() -> Self {
40 Lod::Labeled
41 }
42}
43
44/// Content axis, orthogonal to [`Lod`]. See SRD-63 §3.2.
45///
46/// - [`ContentMode::Value`] — the actual data, normal render.
47/// - [`ContentMode::Explanation`] — same shape and width, but
48/// text describes what the glyphs / abbreviations mean.
49/// Drives the explanation-overlay toggle.
50///
51/// In Push 1 every built-in stubs `Explanation` to a
52/// zero-byte render; Push 7 fills them in.
53#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
54pub enum ContentMode {
55 Value,
56 Explanation,
57}
58
59/// Per-call readout options. Storage = small inline `Vec<(String, OptionValue)>`;
60/// option counts are typically 0-3 so a hashmap is overkill.
61/// SRD-63 §5.1 option grammar — every `key=value` pair the
62/// body parser produces (other than the structural keys
63/// `lod` / `layout` / `color` / `style` which have
64/// dedicated paths) lands here.
65///
66/// Lookup is linear — fine at the option counts we expect.
67/// Two accessor families:
68///
69/// - **Lenient** (`get_str`, `get_int`, …) — return `None`
70/// for both "missing key" and "wrong type." Right for
71/// readouts that fall back to a sensible default.
72/// - **Strict** (`try_get_str`, `try_get_int`, …) — return
73/// `Ok(None)` on missing, `Err(OptionTypeMismatch)` on
74/// type mismatch. Right for readouts that should reject
75/// typo'd configurations rather than silently ignore them.
76#[derive(Default, Clone, Debug)]
77pub struct ReadoutOptions {
78 kv: Vec<(String, OptionValue)>,
79}
80
81/// Error returned by the `try_get_*` accessor family when
82/// the key is present but holds a value of the wrong type.
83/// `None` is reserved for the missing-key case.
84#[derive(Debug, Clone)]
85pub struct OptionTypeMismatch {
86 pub key: String,
87 pub expected: &'static str,
88 pub actual: OptionValue,
89}
90
91impl std::fmt::Display for OptionTypeMismatch {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 write!(
94 f,
95 "option {key:?} expected {exp}, got {act:?}",
96 key = self.key,
97 exp = self.expected,
98 act = self.actual
99 )
100 }
101}
102
103impl std::error::Error for OptionTypeMismatch {}
104
105impl ReadoutOptions {
106 /// Construct an empty option set.
107 pub fn new() -> Self {
108 Self::default()
109 }
110
111 /// Insert / overwrite. Last-one-wins on duplicate
112 /// keys, matching the standard YAML / kv-list
113 /// convention.
114 pub fn set(&mut self, key: impl Into<String>, value: OptionValue) {
115 let key = key.into();
116 if let Some(slot) = self.kv.iter_mut().find(|(k, _)| *k == key) {
117 slot.1 = value;
118 } else {
119 self.kv.push((key, value));
120 }
121 }
122
123 /// Borrow the value for `key`, if any.
124 pub fn get(&self, key: &str) -> Option<&OptionValue> {
125 self.kv.iter().find(|(k, _)| k == key).map(|(_, v)| v)
126 }
127
128 /// Lenient: borrow as `&str` if the key holds a
129 /// string value. Returns `None` for missing key OR
130 /// wrong type. Use [`try_get_str`](Self::try_get_str)
131 /// when the distinction matters.
132 pub fn get_str(&self, key: &str) -> Option<&str> {
133 match self.get(key)? {
134 OptionValue::Str(s) => Some(s),
135 _ => None,
136 }
137 }
138
139 /// Lenient: extract the integer value, if any.
140 pub fn get_int(&self, key: &str) -> Option<i64> {
141 match self.get(key)? {
142 OptionValue::Int(n) => Some(*n),
143 _ => None,
144 }
145 }
146
147 /// Lenient: extract the float value, casting from
148 /// `Int` when needed.
149 pub fn get_float(&self, key: &str) -> Option<f64> {
150 match self.get(key)? {
151 OptionValue::Float(f) => Some(*f),
152 OptionValue::Int(n) => Some(*n as f64),
153 _ => None,
154 }
155 }
156
157 /// Lenient: extract the bool value, if any.
158 pub fn get_bool(&self, key: &str) -> Option<bool> {
159 match self.get(key)? {
160 OptionValue::Bool(b) => Some(*b),
161 _ => None,
162 }
163 }
164
165 /// Strict: `Ok(None)` on missing key, `Ok(Some(...))`
166 /// on string match, `Err(...)` when the key holds a
167 /// non-string value. Per `feedback_never_ignore_silently`:
168 /// callers that mean to reject typo'd options use this
169 /// instead of [`get_str`](Self::get_str).
170 pub fn try_get_str(&self, key: &str) -> Result<Option<&str>, OptionTypeMismatch> {
171 match self.get(key) {
172 None => Ok(None),
173 Some(OptionValue::Str(s)) => Ok(Some(s)),
174 Some(other) => Err(OptionTypeMismatch {
175 key: key.to_string(),
176 expected: "string",
177 actual: other.clone(),
178 }),
179 }
180 }
181
182 /// Strict integer accessor. See [`try_get_str`](Self::try_get_str).
183 pub fn try_get_int(&self, key: &str) -> Result<Option<i64>, OptionTypeMismatch> {
184 match self.get(key) {
185 None => Ok(None),
186 Some(OptionValue::Int(n)) => Ok(Some(*n)),
187 Some(other) => Err(OptionTypeMismatch {
188 key: key.to_string(),
189 expected: "integer",
190 actual: other.clone(),
191 }),
192 }
193 }
194
195 /// Strict float accessor. Accepts `Int` (cast to f64)
196 /// alongside `Float`; rejects everything else.
197 pub fn try_get_float(&self, key: &str) -> Result<Option<f64>, OptionTypeMismatch> {
198 match self.get(key) {
199 None => Ok(None),
200 Some(OptionValue::Float(f)) => Ok(Some(*f)),
201 Some(OptionValue::Int(n)) => Ok(Some(*n as f64)),
202 Some(other) => Err(OptionTypeMismatch {
203 key: key.to_string(),
204 expected: "number",
205 actual: other.clone(),
206 }),
207 }
208 }
209
210 /// Strict bool accessor.
211 pub fn try_get_bool(&self, key: &str) -> Result<Option<bool>, OptionTypeMismatch> {
212 match self.get(key) {
213 None => Ok(None),
214 Some(OptionValue::Bool(b)) => Ok(Some(*b)),
215 Some(other) => Err(OptionTypeMismatch {
216 key: key.to_string(),
217 expected: "bool",
218 actual: other.clone(),
219 }),
220 }
221 }
222
223 /// True iff at least one option is set.
224 pub fn is_empty(&self) -> bool {
225 self.kv.is_empty()
226 }
227
228 /// Iterate every (key, value) pair.
229 pub fn iter(&self) -> impl Iterator<Item = (&str, &OptionValue)> {
230 self.kv.iter().map(|(k, v)| (k.as_str(), v))
231 }
232}
233
234/// Typed option value. The body parser produces `Bool` /
235/// `Int` / `Float` / `Str` from the lexed token; the
236/// structural keys `lod=` / `layout=` / `color=` /
237/// `style=` parse into dedicated slots on the render step,
238/// not into this enum, so there's no `Lod` variant —
239/// readouts that want to read a LOD should look at the
240/// step's baked `lod`, not query the option store.
241#[derive(Clone, Debug)]
242pub enum OptionValue {
243 Bool(bool),
244 Int(i64),
245 Float(f64),
246 Str(String),
247}
248
249/// A named, pure rendering unit. See SRD-63 §1 for the design
250/// contract.
251///
252/// Implementations are stateless — every render is driven
253/// entirely by the [`ReadoutContext`] and [`ReadoutOptions`]
254/// passed in. The trait is `Send + Sync` so a single
255/// registry instance can be shared across the executor and
256/// the surface threads without locking.
257pub trait Readout: Send + Sync {
258 /// Stable, lower-snake-case identifier. Workloads
259 /// reference readouts by this name.
260 fn name(&self) -> &'static str;
261
262 /// Subject kinds this readout is willing to render
263 /// against. The binder validates at bake-time that
264 /// every event slot's subject kind is in this list,
265 /// so a workload binding `phase_outcome` to
266 /// `on_session_end` fails loudly rather than rendering
267 /// silent zeros. Required: every readout declares
268 /// what kinds it works against. No default — silent
269 /// fallback to `[Phase]` would defeat the safety net
270 /// the validation layer is built on.
271 fn accepts(&self) -> &'static [SubjectKind];
272
273 /// Render this readout at the given LOD and content
274 /// mode, drawing data from `ctx`, writing into `out`.
275 /// Returns the rendered byte width so the caller can do
276 /// alignment without a second measurement pass.
277 ///
278 /// Hot-path contract: must not allocate beyond what the
279 /// output buffer's growth requires.
280 fn render(
281 &self,
282 ctx: &dyn ReadoutContext,
283 lod: Lod,
284 mode: ContentMode,
285 opts: &ReadoutOptions,
286 out: &mut dyn ReadoutBuf,
287 ) -> usize;
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn try_get_str_distinguishes_missing_from_wrong_type() {
296 let mut opts = ReadoutOptions::new();
297 opts.set("present", OptionValue::Str("hi".into()));
298 opts.set("wrong_type", OptionValue::Int(7));
299
300 assert!(matches!(opts.try_get_str("absent"), Ok(None)));
301 assert!(matches!(opts.try_get_str("present"), Ok(Some("hi"))));
302 assert!(opts.try_get_str("wrong_type").is_err());
303 }
304
305 #[test]
306 fn try_get_int_rejects_string_typo() {
307 let mut opts = ReadoutOptions::new();
308 opts.set("precision", OptionValue::Str("3".into()));
309 let err = opts.try_get_int("precision").unwrap_err();
310 assert_eq!(err.expected, "integer");
311 assert_eq!(err.key, "precision");
312 }
313
314 #[test]
315 fn try_get_float_accepts_int() {
316 let mut opts = ReadoutOptions::new();
317 opts.set("ratio", OptionValue::Int(2));
318 assert_eq!(opts.try_get_float("ratio").unwrap(), Some(2.0));
319 }
320}