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 SRD 18b §"Iteration variables as scope outputs" for
51//! how comprehension scopes synthesise the `extern` slots
52//! that this module classifies as coordinates.
53
54use indexmap::IndexMap;
55
56use crate::ast::Value;
57
58/// One scope's worth of iteration coordinates — the LHS names
59/// and current values of every `extern <var>: <type>` clause
60/// that scope declared (excluding ones inherited from a parent).
61///
62/// Ordered by declaration position. The map is empty for scopes
63/// that don't own any coordinates (e.g. a scenario node that's
64/// just a list of phases — no comprehension at that level).
65#[derive(Clone, Debug, Default)]
66pub struct ScopeCoord {
67    /// The coordinates, in declaration order.
68    pub vars: IndexMap<String, Value>,
69}
70
71impl ScopeCoord {
72    /// No coordinates.
73    pub fn new() -> Self {
74        Self {
75            vars: IndexMap::new(),
76        }
77    }
78    /// Whether the scope owns no coordinate.
79    pub fn is_empty(&self) -> bool {
80        self.vars.is_empty()
81    }
82    /// The number of coordinates.
83    pub fn len(&self) -> usize {
84        self.vars.len()
85    }
86}
87
88/// Helper for building a coord set from `(name, Value)` pairs.
89impl<I> From<I> for ScopeCoord
90where
91    I: IntoIterator<Item = (String, Value)>,
92{
93    fn from(it: I) -> Self {
94        Self {
95            vars: it.into_iter().collect(),
96        }
97    }
98}
99
100/// Format a scope-coordinate path as striated parens, leaf-first:
101/// `(k=10, limit=20), (table=…, optimize_for=…)`. Empty strata
102/// are skipped, so a chain that passes through a non-comprehension
103/// scope (e.g. a scenario node that's just a phase list) doesn't
104/// render an empty `()`. Returns `""` for an empty path so callers
105/// can wrap with `(…)` parens at their own discretion.
106///
107/// **Canonical structural identity.** This is the formatter every
108/// consumer reasoning about a kernel's iteration position runs
109/// through — runtime executor labels, pre-map walker labels,
110/// inline status lines, scene-tree labels, error messages. Pre-map
111/// and runtime producing the same string for the same iteration
112/// position is what lets observer lifecycle calls bind to
113/// pre-mapped scene nodes without a parallel matching scheme.
114pub fn format_scope_coordinate_path(path: &[ScopeCoord]) -> String {
115    let strata: Vec<String> = path
116        .iter()
117        .filter(|c| !c.is_empty())
118        .map(|coord| {
119            let inner = coord
120                .vars
121                .iter()
122                .map(|(k, v)| format!("{k}={}", v.to_display_string()))
123                .collect::<Vec<_>>()
124                .join(", ");
125            format!("({inner})")
126        })
127        .collect();
128    strata.join(", ")
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn coord(pairs: &[(&str, Value)]) -> ScopeCoord {
136        ScopeCoord::from(
137            pairs
138                .iter()
139                .map(|(k, v)| ((*k).to_string(), v.clone()))
140                .collect::<Vec<_>>(),
141        )
142    }
143
144    /// The exact rendering a consumer matches on: strata leaf-first,
145    /// each in parens, coordinates in declaration order, strata joined
146    /// by a comma and a space.
147    #[test]
148    fn a_path_renders_leaf_first_in_declaration_order() {
149        let path = [
150            coord(&[
151                ("k", Value::U64(10)),
152                ("limit", Value::U64(20)),
153                ("label", Value::Str("hot".into())),
154            ]),
155            coord(&[("table", Value::Str("users".into()))]),
156        ];
157        assert_eq!(
158            format_scope_coordinate_path(&path),
159            "(k=10, limit=20, label=hot), (table=users)"
160        );
161    }
162
163    /// A scope that owns no coordinate contributes nothing, so a chain
164    /// through a non-comprehension scope never renders an empty `()`.
165    #[test]
166    fn an_empty_stratum_is_omitted_anywhere_in_the_path() {
167        let outer = coord(&[("phase", Value::U64(1))]);
168        let inner = coord(&[("k", Value::U64(3))]);
169        let empty = ScopeCoord::new();
170        assert_eq!(
171            format_scope_coordinate_path(&[inner.clone(), empty.clone(), outer.clone()]),
172            "(k=3), (phase=1)"
173        );
174        assert_eq!(
175            format_scope_coordinate_path(&[empty.clone(), inner]),
176            "(k=3)"
177        );
178        assert_eq!(format_scope_coordinate_path(&[empty]), "");
179    }
180
181    /// An empty path is the empty string, so a caller wraps in parens
182    /// at its own discretion without a bare `()` to strip.
183    #[test]
184    fn an_empty_path_is_the_empty_string() {
185        assert_eq!(format_scope_coordinate_path(&[]), "");
186    }
187
188    /// Pre-map and runtime agree by construction: the same coordinates
189    /// render to the same string, which is what lets a lifecycle call
190    /// bind to a pre-mapped node without a second matching scheme.
191    #[test]
192    fn the_same_coordinates_render_to_the_same_string() {
193        let a = [coord(&[("k", Value::U64(7)), ("m", Value::F64(1.5))])];
194        let b = [coord(&[("k", Value::U64(7)), ("m", Value::F64(1.5))])];
195        assert_eq!(
196            format_scope_coordinate_path(&a),
197            format_scope_coordinate_path(&b)
198        );
199        // Order is declaration order, not name order: a different
200        // declaration order is a different position string.
201        let swapped = [coord(&[("m", Value::F64(1.5)), ("k", Value::U64(7))])];
202        assert_ne!(
203            format_scope_coordinate_path(&a),
204            format_scope_coordinate_path(&swapped)
205        );
206    }
207}