Skip to main content

sim_lib_scene/
model.rs

1//! Scene value model: builders, accessors, and fail-closed validation.
2//!
3//! A Scene is a SIM value (an `Expr` tree) built from open maps tagged with a
4//! `kind` symbol. This module never introduces a parallel data model; it only
5//! provides ergonomic constructors over `Expr` and a validator that turns a
6//! malformed scene into a structured [`SceneError`] (a path plus a message)
7//! rather than a panic.
8
9use std::sync::Arc;
10
11use sim_kernel::{Cx, DefaultFactory, Expr, NoopEvalPolicy, ShapeMatch, Symbol};
12
13use crate::kinds::{KIND_KEY, is_known_kind};
14
15const HEATMAP_KIND: &str = "heatmap";
16
17/// Palette names accepted by the domain-neutral `scene/heatmap` contract.
18///
19/// These names are data, not a Rust enum, so a browser renderer can implement
20/// them without introducing a closed device or domain vocabulary.
21pub const HEATMAP_PALETTES: &[&str] = &["viridis", "blue-red", "cyclic-phase"];
22
23/// Scalar payload bytes represented by one heatmap cell (`f64` plus mask bit
24/// stored as a Rust `bool`).
25pub const HEATMAP_BYTES_PER_CELL: u64 =
26    core::mem::size_of::<f64>() as u64 + core::mem::size_of::<bool>() as u64;
27
28/// Calculate the checked scalar-and-metadata payload footprint recorded in a
29/// `scene/heatmap` node.
30///
31/// The footprint intentionally describes the caller-prepared scalar payload,
32/// not a codec-specific serialized size. Surface projections use it as the
33/// stable byte budget across Scene codecs.
34pub fn heatmap_payload_bytes(
35    cells: u64,
36    label: &str,
37    detector: &str,
38    advisory: Option<&str>,
39) -> Option<u64> {
40    let cell_bytes = cells.checked_mul(HEATMAP_BYTES_PER_CELL)?;
41    [Some(label), Some(detector), advisory]
42        .into_iter()
43        .flatten()
44        .try_fold(cell_bytes, |total, text| {
45            total.checked_add(u64::try_from(text.len()).ok()?)
46        })
47}
48
49/// One total budget for producing or rendering a Scene.
50///
51/// `nodes` and `depth` bound structural growth. `encoded_bytes` bounds the
52/// whole scene value as encoded data. `face_bytes` bounds any single rendered
53/// face such as a label, text run, title, or field value.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct SceneBudget {
56    /// Maximum number of scene nodes.
57    pub nodes: usize,
58    /// Maximum nesting depth, with the root at depth 0.
59    pub depth: usize,
60    /// Maximum encoded bytes for the scene value.
61    pub encoded_bytes: usize,
62    /// Maximum bytes for one visible face.
63    pub face_bytes: usize,
64}
65
66impl SceneBudget {
67    /// Create a budget from explicit limits.
68    pub const fn new(nodes: usize, depth: usize, encoded_bytes: usize, face_bytes: usize) -> Self {
69        Self {
70            nodes,
71            depth,
72            encoded_bytes,
73            face_bytes,
74        }
75    }
76
77    /// Default browser-safe budget for generic views.
78    pub const fn interactive() -> Self {
79        Self::new(512, 32, 256 * 1024, 8 * 1024)
80    }
81
82    /// Smaller budget used by tests and compact previews.
83    pub const fn compact() -> Self {
84        Self::new(64, 12, 32 * 1024, 1024)
85    }
86}
87
88/// Mutable receipt for a [`SceneBudget`] as scene producers spend it.
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct SceneBudgetState {
91    budget: SceneBudget,
92    nodes_used: usize,
93    encoded_bytes_used: usize,
94}
95
96impl SceneBudgetState {
97    /// Start spending `budget`.
98    pub fn new(budget: SceneBudget) -> Self {
99        Self {
100            budget,
101            nodes_used: 0,
102            encoded_bytes_used: 0,
103        }
104    }
105
106    /// The immutable limits this state enforces.
107    pub fn budget(&self) -> &SceneBudget {
108        &self.budget
109    }
110
111    /// Number of scene nodes admitted so far.
112    pub fn nodes_used(&self) -> usize {
113        self.nodes_used
114    }
115
116    /// Number of approximate encoded bytes admitted so far.
117    pub fn encoded_bytes_used(&self) -> usize {
118        self.encoded_bytes_used
119    }
120
121    /// Try to admit one node at `depth` with a visible face and encoded-size
122    /// estimate. Returns a truncation reason when the budget is exhausted.
123    pub fn admit(
124        &mut self,
125        depth: usize,
126        face: Option<&str>,
127        encoded_bytes: usize,
128    ) -> Result<(), SceneBudgetExhausted> {
129        if self.nodes_used >= self.budget.nodes {
130            return Err(SceneBudgetExhausted::Nodes {
131                limit: self.budget.nodes,
132            });
133        }
134        if depth > self.budget.depth {
135            return Err(SceneBudgetExhausted::Depth {
136                limit: self.budget.depth,
137            });
138        }
139        if let Some(face) = face
140            && face.len() > self.budget.face_bytes
141        {
142            return Err(SceneBudgetExhausted::FaceBytes {
143                limit: self.budget.face_bytes,
144            });
145        }
146        if self.encoded_bytes_used.saturating_add(encoded_bytes) > self.budget.encoded_bytes {
147            return Err(SceneBudgetExhausted::EncodedBytes {
148                limit: self.budget.encoded_bytes,
149            });
150        }
151        self.nodes_used += 1;
152        self.encoded_bytes_used = self.encoded_bytes_used.saturating_add(encoded_bytes);
153        Ok(())
154    }
155}
156
157/// Reason a Scene budget refused another node.
158#[derive(Clone, Debug, PartialEq, Eq)]
159pub enum SceneBudgetExhausted {
160    /// The node count was exhausted.
161    Nodes {
162        /// Configured node limit.
163        limit: usize,
164    },
165    /// The depth limit was exhausted.
166    Depth {
167        /// Configured depth limit.
168        limit: usize,
169    },
170    /// The total encoded-byte limit was exhausted.
171    EncodedBytes {
172        /// Configured encoded-byte limit.
173        limit: usize,
174    },
175    /// A single visible face exceeded the per-face limit.
176    FaceBytes {
177        /// Configured per-face byte limit.
178        limit: usize,
179    },
180}
181
182impl SceneBudgetExhausted {
183    /// Stable reason token for scene truncation metadata.
184    pub fn reason(&self) -> &'static str {
185        match self {
186            Self::Nodes { .. } => "nodes",
187            Self::Depth { .. } => "depth",
188            Self::EncodedBytes { .. } => "encoded-bytes",
189            Self::FaceBytes { .. } => "face-bytes",
190        }
191    }
192
193    /// Configured limit that was exceeded.
194    pub fn limit(&self) -> usize {
195        match self {
196            Self::Nodes { limit }
197            | Self::Depth { limit }
198            | Self::EncodedBytes { limit }
199            | Self::FaceBytes { limit } => *limit,
200        }
201    }
202}
203
204/// A structured scene validation diagnostic: where the problem is and what it
205/// is. `path` is a human-readable address into the scene tree (for example
206/// `nodes[0].kind`); `message` describes the violation.
207#[derive(Clone, Debug, PartialEq, Eq)]
208pub struct SceneError {
209    /// Address into the scene tree, outermost segment first.
210    pub path: Vec<String>,
211    /// Human-readable description of the violation.
212    pub message: String,
213}
214
215impl SceneError {
216    fn at(path: &[String], message: impl Into<String>) -> Self {
217        Self {
218            path: path.to_vec(),
219            message: message.into(),
220        }
221    }
222
223    /// Render the path as a dotted/indexed address, or `<root>` when empty.
224    pub fn path_string(&self) -> String {
225        if self.path.is_empty() {
226            "<root>".to_owned()
227        } else {
228            self.path.join("")
229        }
230    }
231}
232
233impl core::fmt::Display for SceneError {
234    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
235        write!(f, "{}: {}", self.path_string(), self.message)
236    }
237}
238
239/// Build a plain data map from string-keyed entries (keys become `core`-less
240/// symbols). Use [`node`] to build a tagged scene node.
241pub use sim_value::build::map;
242
243/// Build a scene node: an `Expr::Map` whose first entry is `kind: scene/<name>`
244/// followed by `entries`.
245pub fn node(kind_name: &str, entries: Vec<(&str, Expr)>) -> Expr {
246    let mut pairs = Vec::with_capacity(entries.len() + 1);
247    pairs.push((
248        Expr::Symbol(Symbol::new(KIND_KEY)),
249        Expr::Symbol(Symbol::qualified(crate::kinds::SCENE_NAMESPACE, kind_name)),
250    ));
251    for (key, value) in entries {
252        pairs.push((Expr::Symbol(Symbol::new(key)), value));
253    }
254    Expr::Map(pairs)
255}
256
257/// If `expr` is a map tagged with a symbol `kind`, return that kind symbol.
258pub fn node_kind(expr: &Expr) -> Option<Symbol> {
259    sim_value::access::field_sym(expr, KIND_KEY)
260}
261
262fn kind_entry(map: &Expr) -> Option<&Expr> {
263    sim_value::access::field(map, KIND_KEY)
264}
265
266fn has_kind_key(map: &Expr) -> bool {
267    kind_entry(map).is_some()
268}
269
270/// Validate that `expr` is a well-formed scene, failing closed with a
271/// [`SceneError`] otherwise.
272///
273/// The root must be a scene node (a map tagged with a recognized `scene/<kind>`
274/// symbol). Nested maps that carry a `kind` key are validated as scene nodes
275/// too; maps without a `kind` key are treated as plain data and only recursed
276/// into. This keeps the metadata open (arbitrary data may ride along) while
277/// still rejecting a map that claims to be a scene node but is not one.
278pub fn validate_scene(expr: &Expr) -> Result<(), SceneError> {
279    let mut path = Vec::new();
280    validate_node(expr, &mut path)
281}
282
283fn validate_node(expr: &Expr, path: &mut Vec<String>) -> Result<(), SceneError> {
284    let shape_error = check_scene_shape(expr, path)?;
285    let Expr::Map(entries) = expr else {
286        return Err(SceneError::at(
287            path,
288            "expected a scene node map (an Expr::Map tagged with a kind)",
289        ));
290    };
291    match kind_entry(expr) {
292        None => {
293            return Err(SceneError::at(path, "scene node is missing a 'kind' tag"));
294        }
295        Some(Expr::Symbol(kind)) => {
296            if !is_known_kind(kind) {
297                return Err(SceneError::at(
298                    path,
299                    format!(
300                        "unrecognized scene kind '{kind}' -- if this is a plain data map, \
301                         rename its 'kind' field (scene node maps reserve 'kind')"
302                    ),
303                ));
304            }
305        }
306        Some(_) => {
307            return Err(SceneError::at(path, "scene node 'kind' must be a symbol"));
308        }
309    }
310    if let Some(message) = shape_error {
311        return Err(SceneError::at(path, message));
312    }
313    if matches!(
314        node_kind(expr),
315        Some(kind)
316            if kind.namespace.as_deref() == Some(crate::kinds::SCENE_NAMESPACE)
317                && &*kind.name == HEATMAP_KIND
318    ) {
319        validate_heatmap(expr, path)?;
320    }
321    validate_children(entries, path)
322}
323
324fn validate_heatmap(expr: &Expr, path: &[String]) -> Result<(), SceneError> {
325    let rows = heatmap_u64(expr, "rows", path)?;
326    let cols = heatmap_u64(expr, "cols", path)?;
327    if rows == 0 || cols == 0 {
328        return Err(SceneError::at(
329            path,
330            "scene/heatmap rows and cols must be non-zero",
331        ));
332    }
333    let cells = rows.checked_mul(cols).ok_or_else(|| {
334        SceneError::at(path, "scene/heatmap rows * cols overflows the cell count")
335    })?;
336    let cell_count = usize::try_from(cells).map_err(|_| {
337        SceneError::at(
338            path,
339            "scene/heatmap cell count cannot be represented on this host",
340        )
341    })?;
342
343    let values = heatmap_list(expr, "values", path)?;
344    let valid = heatmap_list(expr, "valid", path)?;
345    if values.len() != cell_count {
346        return Err(SceneError::at(
347            path,
348            format!(
349                "scene/heatmap rows * cols is {cells}, but values has {} entries",
350                values.len()
351            ),
352        ));
353    }
354    if valid.len() != cell_count {
355        return Err(SceneError::at(
356            path,
357            format!(
358                "scene/heatmap rows * cols is {cells}, but valid has {} entries",
359                valid.len()
360            ),
361        ));
362    }
363    for (index, value) in values.iter().enumerate() {
364        let Some(value) = sim_value::access::as_f64(value) else {
365            return Err(SceneError::at(
366                path,
367                format!("scene/heatmap values[{index}] must be a number"),
368            ));
369        };
370        if !value.is_finite() {
371            return Err(SceneError::at(
372                path,
373                format!("scene/heatmap values[{index}] must be finite"),
374            ));
375        }
376    }
377    if let Some(index) = valid
378        .iter()
379        .position(|value| !matches!(value, Expr::Bool(_)))
380    {
381        return Err(SceneError::at(
382            path,
383            format!("scene/heatmap valid[{index}] must be a bool"),
384        ));
385    }
386
387    let min = heatmap_f64(expr, "min", path)?;
388    let max = heatmap_f64(expr, "max", path)?;
389    if !min.is_finite() || !max.is_finite() || min > max {
390        return Err(SceneError::at(
391            path,
392            "scene/heatmap range must be finite with min <= max",
393        ));
394    }
395
396    let palette = sim_value::access::field_sym(expr, "palette")
397        .filter(|palette| palette.namespace.is_none())
398        .ok_or_else(|| {
399            SceneError::at(path, "scene/heatmap palette must be an unqualified symbol")
400        })?;
401    if !HEATMAP_PALETTES.contains(&palette.name.as_ref()) {
402        return Err(SceneError::at(
403            path,
404            format!("scene/heatmap palette '{}' is not recognized", palette.name),
405        ));
406    }
407
408    let label = heatmap_nonempty_text(expr, "label", path)?;
409    let detector = heatmap_nonempty_text(expr, "detector", path)?;
410    let advisory = sim_value::access::field(expr, "advisory")
411        .map(|_| heatmap_nonempty_text(expr, "advisory", path))
412        .transpose()?;
413
414    let footprint = sim_value::access::field(expr, "footprint")
415        .ok_or_else(|| SceneError::at(path, "scene/heatmap footprint is required"))?;
416    let footprint_cells = heatmap_u64(footprint, "cells", path)?;
417    if footprint_cells != cells {
418        return Err(SceneError::at(
419            path,
420            format!("scene/heatmap footprint cells is {footprint_cells}, expected {cells}"),
421        ));
422    }
423    let payload_bytes = heatmap_payload_bytes(cells, label, detector, advisory)
424        .ok_or_else(|| SceneError::at(path, "scene/heatmap byte footprint overflowed"))?;
425    let footprint_bytes = heatmap_u64(footprint, "bytes", path)?;
426    if footprint_bytes != payload_bytes {
427        return Err(SceneError::at(
428            path,
429            format!("scene/heatmap footprint bytes is {footprint_bytes}, expected {payload_bytes}"),
430        ));
431    }
432    Ok(())
433}
434
435fn heatmap_list<'a>(expr: &'a Expr, name: &str, path: &[String]) -> Result<&'a [Expr], SceneError> {
436    match sim_value::access::field(expr, name) {
437        Some(Expr::List(items)) => Ok(items),
438        _ => Err(SceneError::at(
439            path,
440            format!("scene/heatmap {name} must be a list"),
441        )),
442    }
443}
444
445fn heatmap_u64(expr: &Expr, name: &str, path: &[String]) -> Result<u64, SceneError> {
446    sim_value::access::field(expr, name)
447        .and_then(|value| match value {
448            Expr::Number(number)
449                if matches!(number.domain.name.as_ref(), "i64" | "u64")
450                    && number.domain.namespace.is_none() =>
451            {
452                number.canonical.parse::<u64>().ok()
453            }
454            _ => None,
455        })
456        .ok_or_else(|| {
457            SceneError::at(
458                path,
459                format!("scene/heatmap {name} must be a non-negative integer number"),
460            )
461        })
462}
463
464fn heatmap_f64(expr: &Expr, name: &str, path: &[String]) -> Result<f64, SceneError> {
465    sim_value::access::field(expr, name)
466        .and_then(sim_value::access::as_f64)
467        .ok_or_else(|| SceneError::at(path, format!("scene/heatmap {name} must be a number")))
468}
469
470fn heatmap_nonempty_text<'a>(
471    expr: &'a Expr,
472    name: &str,
473    path: &[String],
474) -> Result<&'a str, SceneError> {
475    let text = sim_value::access::field_str(expr, name)
476        .ok_or_else(|| SceneError::at(path, format!("scene/heatmap {name} must be a string")))?;
477    if text.trim().is_empty() {
478        return Err(SceneError::at(
479            path,
480            format!("scene/heatmap {name} must not be empty"),
481        ));
482    }
483    Ok(text)
484}
485
486fn check_scene_shape(expr: &Expr, path: &[String]) -> Result<Option<String>, SceneError> {
487    let mut cx = Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory));
488    let matched = crate::shapes::scene_shape()
489        .check_expr(&mut cx, expr)
490        .map_err(|error| SceneError::at(path, format!("scene shape check failed: {error}")))?;
491    Ok((!matched.accepted)
492        .then(|| rejection_message(&matched, "value is not a recognized scene node")))
493}
494
495fn rejection_message(matched: &ShapeMatch, fallback: &str) -> String {
496    matched
497        .diagnostics
498        .first()
499        .map(|diagnostic| diagnostic.message.clone())
500        .unwrap_or_else(|| fallback.to_owned())
501}
502
503fn validate_children(entries: &[(Expr, Expr)], path: &mut Vec<String>) -> Result<(), SceneError> {
504    for (key, value) in entries {
505        let label = match key {
506            Expr::Symbol(symbol) => format!(".{}", symbol.as_qualified_str()),
507            other => format!(".{other:?}"),
508        };
509        path.push(label);
510        validate_data(value, path)?;
511        path.pop();
512    }
513    Ok(())
514}
515
516fn validate_data(expr: &Expr, path: &mut Vec<String>) -> Result<(), SceneError> {
517    match expr {
518        Expr::Map(_) if has_kind_key(expr) => validate_node(expr, path),
519        Expr::Map(entries) => validate_children(entries, path),
520        Expr::List(items) | Expr::Vector(items) | Expr::Set(items) => {
521            for (index, item) in items.iter().enumerate() {
522                path.push(format!("[{index}]"));
523                validate_data(item, path)?;
524                path.pop();
525            }
526            Ok(())
527        }
528        _ => Ok(()),
529    }
530}