Skip to main content

polydat_core/kernel/
scope.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Scope coordinates — the formal Polydat-side model of the
5//! iteration position a kernel occupies inside an enclosing
6//! comprehension chain.
7//!
8//! ## Definition
9//!
10//! A **scope coordinate set** for a single scope is the
11//! ordered name→value tuple of every iteration extern that
12//! scope owns — i.e. variables the scope declared via
13//! `extern <var>: <type>` and that aren't mirroring an outer
14//! scope (`is_inherited` returns false in this scope's program).
15//! The order is the declaration order from the comprehension's
16//! source (preserved by `IndexMap`'s insertion semantics).
17//!
18//! A **scope coordinate path** is the leaf-first list of
19//! coordinate sets, walking from the kernel's own scope up
20//! through every enclosing comprehension scope. Workload-root
21//! params (top-level `params:` in the document) don't
22//! contribute — they're configuration, not iteration
23//! coordinates.
24//!
25//! ## Invariant
26//!
27//! Every kernel that has been *initialised in its scope* —
28//! either via `PolydatKernel::build_subscope` (post-bind
29//! the path is `[own] ++ outer.scope_coordinates()`), or as a
30//! root scope (path is `[own]` if non-empty, else empty) —
31//! has [`super::PolydatKernel::scope_coordinates`] populated. This
32//! is treated as a structural invariant of the Polydat model, not
33//! an optional add-on: the runtime contract is that any
34//! consumer (presentation layer, inspector, future scope-aware
35//! diagnostics) can call `scope_coordinates()` on an
36//! initialised kernel and get the full path back without
37//! needing to walk the scope tree itself.
38//!
39//! ## Use
40//!
41//! Presentation-layer consumers (the inline status line, TUI
42//! phase rows, the inspector socket) render the path as
43//! striated parens — `(leaf coords), (parent coords), …` —
44//! so the operator can read the active iteration off each
45//! enclosing scope at a glance. Without striation the
46//! operator can't tell which `k=10` belongs to the inner
47//! comprehension vs. an outer one with the same coord name
48//! in a different shape.
49//!
50//! See scope_model.md §3 and §7 for the iteration-extern slots
51//! that this module classifies as coordinates, and
52//! for_traversal.md §4 for how a `for` body declares one per
53//! element.
54
55use indexmap::IndexMap;
56
57use crate::ast::Value;
58
59/// One scope's worth of iteration coordinates — the LHS names
60/// and current values of every `extern <var>: <type>` clause
61/// that scope declared (excluding ones inherited from a parent).
62///
63/// Ordered by declaration position. The map is empty for scopes
64/// that don't own any coordinates (e.g. a scenario node that's
65/// just a list of phases — no comprehension at that level).
66#[derive(Clone, Debug, Default)]
67pub struct ScopeCoord {
68    /// The coordinates, in declaration order.
69    pub vars: IndexMap<String, Value>,
70}
71
72impl ScopeCoord {
73    /// No coordinates.
74    pub fn new() -> Self {
75        Self {
76            vars: IndexMap::new(),
77        }
78    }
79    /// Whether the scope owns no coordinate.
80    pub fn is_empty(&self) -> bool {
81        self.vars.is_empty()
82    }
83    /// The number of coordinates.
84    pub fn len(&self) -> usize {
85        self.vars.len()
86    }
87}
88
89/// Helper for building a coord set from `(name, Value)` pairs.
90impl<I> From<I> for ScopeCoord
91where
92    I: IntoIterator<Item = (String, Value)>,
93{
94    fn from(it: I) -> Self {
95        Self {
96            vars: it.into_iter().collect(),
97        }
98    }
99}
100
101/// Format a scope-coordinate path as striated parens, leaf-first:
102/// `(k=10, limit=20), (table=…, optimize_for=…)`. Empty strata
103/// are skipped, so a chain that passes through a non-comprehension
104/// scope (e.g. a scenario node that's just a phase list) doesn't
105/// render an empty `()`. Returns `""` for an empty path so callers
106/// can wrap with `(…)` parens at their own discretion.
107///
108/// **Canonical structural identity.** This is the formatter every
109/// consumer reasoning about a kernel's iteration position runs
110/// through — runtime executor labels, pre-map walker labels,
111/// inline status lines, scene-tree labels, error messages. Pre-map
112/// and runtime producing the same string for the same iteration
113/// position is what lets observer lifecycle calls bind to
114/// pre-mapped scene nodes without a parallel matching scheme.
115pub fn format_scope_coordinate_path(path: &[ScopeCoord]) -> String {
116    let strata: Vec<String> = path
117        .iter()
118        .filter(|c| !c.is_empty())
119        .map(|coord| {
120            let inner = coord
121                .vars
122                .iter()
123                .map(|(k, v)| format!("{k}={}", v.to_display_string()))
124                .collect::<Vec<_>>()
125                .join(", ");
126            format!("({inner})")
127        })
128        .collect();
129    strata.join(", ")
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    fn coord(pairs: &[(&str, Value)]) -> ScopeCoord {
137        ScopeCoord::from(
138            pairs
139                .iter()
140                .map(|(k, v)| ((*k).to_string(), v.clone()))
141                .collect::<Vec<_>>(),
142        )
143    }
144
145    /// The exact rendering a consumer matches on: strata leaf-first,
146    /// each in parens, coordinates in declaration order, strata joined
147    /// by a comma and a space.
148    #[test]
149    fn a_path_renders_leaf_first_in_declaration_order() {
150        let path = [
151            coord(&[
152                ("k", Value::U64(10)),
153                ("limit", Value::U64(20)),
154                ("label", Value::Str("hot".into())),
155            ]),
156            coord(&[("table", Value::Str("users".into()))]),
157        ];
158        assert_eq!(
159            format_scope_coordinate_path(&path),
160            "(k=10, limit=20, label=hot), (table=users)"
161        );
162    }
163
164    /// A scope that owns no coordinate contributes nothing, so a chain
165    /// through a non-comprehension scope never renders an empty `()`.
166    #[test]
167    fn an_empty_stratum_is_omitted_anywhere_in_the_path() {
168        let outer = coord(&[("phase", Value::U64(1))]);
169        let inner = coord(&[("k", Value::U64(3))]);
170        let empty = ScopeCoord::new();
171        assert_eq!(
172            format_scope_coordinate_path(&[inner.clone(), empty.clone(), outer.clone()]),
173            "(k=3), (phase=1)"
174        );
175        assert_eq!(
176            format_scope_coordinate_path(&[empty.clone(), inner]),
177            "(k=3)"
178        );
179        assert_eq!(format_scope_coordinate_path(&[empty]), "");
180    }
181
182    /// An empty path is the empty string, so a caller wraps in parens
183    /// at its own discretion without a bare `()` to strip.
184    #[test]
185    fn an_empty_path_is_the_empty_string() {
186        assert_eq!(format_scope_coordinate_path(&[]), "");
187    }
188
189    /// Pre-map and runtime agree by construction: the same coordinates
190    /// render to the same string, which is what lets a lifecycle call
191    /// bind to a pre-mapped node without a second matching scheme.
192    #[test]
193    fn the_same_coordinates_render_to_the_same_string() {
194        let a = [coord(&[("k", Value::U64(7)), ("m", Value::F64(1.5))])];
195        let b = [coord(&[("k", Value::U64(7)), ("m", Value::F64(1.5))])];
196        assert_eq!(
197            format_scope_coordinate_path(&a),
198            format_scope_coordinate_path(&b)
199        );
200        // Order is declaration order, not name order: a different
201        // declaration order is a different position string.
202        let swapped = [coord(&[("m", Value::F64(1.5)), ("k", Value::U64(7))])];
203        assert_ne!(
204            format_scope_coordinate_path(&a),
205            format_scope_coordinate_path(&swapped)
206        );
207    }
208}