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}