1use 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
17pub const HEATMAP_PALETTES: &[&str] = &["viridis", "blue-red", "cyclic-phase"];
22
23pub const HEATMAP_BYTES_PER_CELL: u64 =
26 core::mem::size_of::<f64>() as u64 + core::mem::size_of::<bool>() as u64;
27
28pub 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#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct SceneBudget {
56 pub nodes: usize,
58 pub depth: usize,
60 pub encoded_bytes: usize,
62 pub face_bytes: usize,
64}
65
66impl SceneBudget {
67 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 pub const fn interactive() -> Self {
79 Self::new(512, 32, 256 * 1024, 8 * 1024)
80 }
81
82 pub const fn compact() -> Self {
84 Self::new(64, 12, 32 * 1024, 1024)
85 }
86}
87
88#[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 pub fn new(budget: SceneBudget) -> Self {
99 Self {
100 budget,
101 nodes_used: 0,
102 encoded_bytes_used: 0,
103 }
104 }
105
106 pub fn budget(&self) -> &SceneBudget {
108 &self.budget
109 }
110
111 pub fn nodes_used(&self) -> usize {
113 self.nodes_used
114 }
115
116 pub fn encoded_bytes_used(&self) -> usize {
118 self.encoded_bytes_used
119 }
120
121 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#[derive(Clone, Debug, PartialEq, Eq)]
159pub enum SceneBudgetExhausted {
160 Nodes {
162 limit: usize,
164 },
165 Depth {
167 limit: usize,
169 },
170 EncodedBytes {
172 limit: usize,
174 },
175 FaceBytes {
177 limit: usize,
179 },
180}
181
182impl SceneBudgetExhausted {
183 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 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#[derive(Clone, Debug, PartialEq, Eq)]
208pub struct SceneError {
209 pub path: Vec<String>,
211 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 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
239pub use sim_value::build::map;
242
243pub 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
257pub 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
270pub 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}