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}