Skip to main content

nmbrs_workload/
polydat_matter.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! SRD-13d §3.1 — declarative GK-content classification for
5//! every node in the workload AST. The scope-tree pre-walker
6//! uses [`HasPolydatMatter::polydat_matter`] as the **first question** at
7//! every scope decision: most workloads short-circuit here
8//! and never reach the program-hash-equivalence refinement
9//! (§3.2).
10//!
11//! Implementations cover the AST types — runtime objects
12//! (the `Component` tree, fibers, dispensers) consume the
13//! marks the trait produced; they don't implement the trait
14//! themselves. Polydat content lives on the AST, not on runtime
15//! state.
16
17use crate::model::{BindingsDef, ParsedOp, ScenarioNode, Workload, WorkloadPhase};
18
19/// SRD-13d §3.1 classification of how much Polydat content a
20/// scope-tree node carries.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum PolydatMatter {
23    /// No Polydat references at all — no `bindings:`, no
24    /// `metrics:`, no inline `{{<expr>}}`, no GK-typed
25    /// fields. Walker skips kernel construction entirely.
26    None,
27    /// References parent-scope Polydat names but **defines
28    /// nothing new**. Examples: `metrics:` declarations whose
29    /// `value:` is a bare name resolving to a parent binding;
30    /// inline `{{<name>}}` substitution where `<name>` is a
31    /// parent binding; op fields that bind parent-scope
32    /// wires without declaring new ones. Walker skips kernel
33    /// construction; reads thread through the parent's
34    /// kernel state directly.
35    Readonly,
36    /// Declares new bindings, wire expressions, or constants
37    /// that the parent doesn't supply. Walker materialises a
38    /// kernel for this node — possibly subject to hash-check
39    /// flattening (§3.2) if the new content turns out to be
40    /// equivalent to the parent's.
41    Definitions,
42}
43
44/// Implemented by every workload-AST type that can sit in
45/// the construction tree. Pure function of the parsed AST;
46/// no runtime state, no compilation.
47pub trait HasPolydatMatter {
48    /// Classify this node's contribution to Polydat content.
49    fn polydat_matter(&self) -> PolydatMatter;
50}
51
52// -----------------------------------------------------------
53// Helpers
54// -----------------------------------------------------------
55
56/// `bindings:` block contributes definitions when non-empty.
57fn bindings_def_matter(b: &BindingsDef) -> PolydatMatter {
58    if b.is_empty() {
59        PolydatMatter::None
60    } else {
61        PolydatMatter::Definitions
62    }
63}
64
65/// True when any value field on the op uses inline `{{<expr>}}`
66/// substitution. Promotes to `Definitions` because the
67/// rewrite pass (`crate::scope::rewrite_inline_exprs` in
68/// nmbrs-runtime) hoists each into a `__expr_N := <expr>`
69/// binding owned by the op.
70fn has_inline_expr(op: &ParsedOp) -> bool {
71    fn scan(v: &serde_json::Value) -> bool {
72        match v {
73            serde_json::Value::String(s) => s.contains("{{") && s.contains("}}"),
74            serde_json::Value::Array(arr) => arr.iter().any(scan),
75            serde_json::Value::Object(map) => map.values().any(scan),
76            _ => false,
77        }
78    }
79    op.op.values().any(scan) || op.params.values().any(scan)
80}
81
82// -----------------------------------------------------------
83// Trait impls
84// -----------------------------------------------------------
85
86impl HasPolydatMatter for ParsedOp {
87    fn polydat_matter(&self) -> PolydatMatter {
88        // Op-level bindings always promote when non-empty.
89        let by_bindings = bindings_def_matter(&self.bindings);
90        if by_bindings == PolydatMatter::Definitions {
91            return PolydatMatter::Definitions;
92        }
93
94        // Inline `{{<expr>}}` constructs on any op field
95        // hoist into anonymous bindings during pre-compile.
96        if has_inline_expr(self) {
97            return PolydatMatter::Definitions;
98        }
99
100        // metrics: any declared metric contributes Definitions.
101        // Post-SRD-68 follow-up: the op-template synthesiser
102        // appends a `__metric_<name> := <value_expr>` binding per
103        // metric (see `crate::scope::synthesize_metric_binding_name`)
104        // so the closure-binding economy can walk the value
105        // expression's free identifiers for magic-extern slot
106        // allocation. That synthesised binding is itself a
107        // definition, so even a bare-name `value: count` requires
108        // an op-template kernel — the kernel is where the
109        // synthesised `__metric_<name>` LHS lands.
110        if !self.metrics.is_empty() {
111            return PolydatMatter::Definitions;
112        }
113
114        // result: declarations expose result-body fields as
115        // Polydat wires — a definition by construction (the
116        // wire didn't exist before this op).
117        if self.result.as_ref().is_some_and(|r| !r.is_empty()) {
118            return PolydatMatter::Definitions;
119        }
120
121        // capture: writes result-extracted values onto Polydat
122        // wires — definitions by construction, same as `result:`.
123        // The op-template kernel is where the capture target's
124        // slot lives; when the target is an ancestral `shared`
125        // cell, that slot is what the wiring cascade cell-binds.
126        // Without materialisation the cycle-time write lands on a
127        // non-cell fallback and the captured value never crosses
128        // the phase boundary — a later phase gating on the cell
129        // reads its initializer forever.
130        if !self.captures.is_empty() {
131            return PolydatMatter::Definitions;
132        }
133
134        // Anything that gets here reads parent bindings only
135        // (e.g. `if:` / `delay:` references) or has nothing
136        // GK-shaped at all.
137        if self.condition.is_some() || self.delay.is_some() {
138            PolydatMatter::Readonly
139        } else {
140            PolydatMatter::None
141        }
142    }
143}
144
145impl HasPolydatMatter for WorkloadPhase {
146    fn polydat_matter(&self) -> PolydatMatter {
147        // Phase-level `bindings:` block on the phase AST.
148        // Today's parser also legacy-merges this into per-op
149        // bindings; the phase still owns the structural fact
150        // that it declared the binding (SRD-13d §3.1's
151        // classification operates on the AST, not on the
152        // post-merge runtime view).
153        let by_bindings = bindings_def_matter(&self.bindings);
154        if by_bindings == PolydatMatter::Definitions {
155            return PolydatMatter::Definitions;
156        }
157        // Phase-level `metrics:` synthesise `volatile __metric_<name>
158        // := <value>` (plus the injected `phase_start` extern) onto
159        // the phase kernel — definitions by construction, so the
160        // phase needs its own scope kernel even with no `bindings:`.
161        if !self.metrics.is_empty() {
162            return PolydatMatter::Definitions;
163        }
164        // `for_each:` clauses always bind iteration variables.
165        if self.for_each.is_some() {
166            return PolydatMatter::Definitions;
167        }
168        // `cycles` / `concurrency` referencing workload-param
169        // Polydat names (`{train_count}` etc.) ⇒ Readonly. `rate`
170        // is f64-typed today (no Polydat refs) but counted as a
171        // parent reference for symmetry; revisit when rate
172        // grows GK-expression support.
173        let refs_parent = [&self.cycles, &self.concurrency].iter().any(|opt| {
174            opt.as_ref()
175                .is_some_and(|s| s.contains('{') && s.contains('}'))
176        });
177        if refs_parent || self.rate.is_some() {
178            return PolydatMatter::Readonly;
179        }
180        PolydatMatter::None
181    }
182}
183
184impl HasPolydatMatter for ScenarioNode {
185    fn polydat_matter(&self) -> PolydatMatter {
186        match self {
187            // Iteration constructs always declare iteration
188            // variables — Definitions by construction.
189            ScenarioNode::Comprehension { .. }
190            | ScenarioNode::DoWhile { .. }
191            | ScenarioNode::DoUntil { .. } => PolydatMatter::Definitions,
192            // Phase reference + scenario-include wrappers
193            // don't add Polydat content on their own; the
194            // wrapped phase / included scenario carries it.
195            ScenarioNode::Phase(_) | ScenarioNode::IncludedScenario { .. } => PolydatMatter::None,
196            // Scenario-tree-level `bindings:` block (also the
197            // canonical lowered form of `set:` sugar) installs
198            // a Polydat scope kernel — Definitions by definition.
199            ScenarioNode::Bindings { .. } => PolydatMatter::Definitions,
200        }
201    }
202}
203
204impl HasPolydatMatter for Workload {
205    fn polydat_matter(&self) -> PolydatMatter {
206        // Workload root carries the top-level `bindings:`
207        // block + workload-level params. Either contributes
208        // Definitions when non-empty.
209        let by_bindings = bindings_def_matter(&self.bindings);
210        if by_bindings == PolydatMatter::Definitions {
211            return PolydatMatter::Definitions;
212        }
213        if !self.params.is_empty() {
214            // Params turn into `final <name> := <literal>`
215            // bindings on the workload-params kernel
216            // (crates/nmbrs-runtime/src/params.rs), so any param
217            // declaration is Polydat content.
218            return PolydatMatter::Definitions;
219        }
220        PolydatMatter::None
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::model::{MetricSpec, ResultSpec};
228
229    fn empty_op(name: &str) -> ParsedOp {
230        ParsedOp::simple(name, "noop")
231    }
232
233    // ── ParsedOp ─────────────────────────────────────────
234
235    #[test]
236    fn parsed_op_with_no_polydat_content_is_none() {
237        let op = empty_op("x");
238        assert_eq!(op.polydat_matter(), PolydatMatter::None);
239    }
240
241    #[test]
242    fn parsed_op_with_bindings_is_definitions() {
243        let mut op = empty_op("x");
244        op.bindings = BindingsDef::PolydatSource("k := 5".into());
245        assert_eq!(op.polydat_matter(), PolydatMatter::Definitions);
246    }
247
248    #[test]
249    fn parsed_op_empty_bindings_string_is_none() {
250        let mut op = empty_op("x");
251        op.bindings = BindingsDef::PolydatSource("   \n  ".into());
252        assert_eq!(op.polydat_matter(), PolydatMatter::None);
253    }
254
255    #[test]
256    fn parsed_op_with_inline_expr_is_definitions() {
257        let mut op = empty_op("x");
258        op.op.insert(
259            "stmt".into(),
260            serde_json::Value::String("SELECT {{cycle}}".into()),
261        );
262        assert_eq!(op.polydat_matter(), PolydatMatter::Definitions);
263    }
264
265    #[test]
266    fn parsed_op_metrics_bare_value_is_definitions() {
267        // Post-SRD-68 follow-up: every metric synthesises a
268        // `__metric_<name> := <value>` binding into the
269        // op-template kernel — even bare-name `value:` forms
270        // become a definition (the synthesised LHS is the
271        // definition), which requires an op-template kernel
272        // to exist. Used to return Readonly back when
273        // MetricsDispenser read parent bindings directly;
274        // that path is gone.
275        let mut op = empty_op("x");
276        op.metrics.insert(
277            "foo".into(),
278            MetricSpec {
279                value: "existing_wire".into(),
280                family: None,
281                kind: None,
282                unit: None,
283                format: None,
284                cell: Default::default(),
285            },
286        );
287        assert_eq!(op.polydat_matter(), PolydatMatter::Definitions);
288    }
289
290    #[test]
291    fn parsed_op_metrics_dotted_name_is_definitions() {
292        // Same as above; dotted-name forms equally require a
293        // synthesised `__metric_<name>` binding.
294        let mut op = empty_op("x");
295        op.metrics.insert(
296            "foo".into(),
297            MetricSpec {
298                value: "phase.recall_at_10".into(),
299                family: None,
300                kind: None,
301                unit: None,
302                format: None,
303                cell: Default::default(),
304            },
305        );
306        assert_eq!(op.polydat_matter(), PolydatMatter::Definitions);
307    }
308
309    #[test]
310    fn parsed_op_metrics_expression_is_definitions() {
311        let mut op = empty_op("x");
312        op.metrics.insert(
313            "foo".into(),
314            MetricSpec {
315                value: "factor * 2.0".into(),
316                family: None,
317                kind: None,
318                unit: None,
319                format: None,
320                cell: Default::default(),
321            },
322        );
323        assert_eq!(op.polydat_matter(), PolydatMatter::Definitions);
324    }
325
326    #[test]
327    fn parsed_op_with_result_is_definitions() {
328        let mut op = empty_op("x");
329        let mut entries = std::collections::BTreeMap::new();
330        entries.insert("rows_returned".into(), "count".into());
331        op.result = Some(ResultSpec::Map(entries));
332        assert_eq!(op.polydat_matter(), PolydatMatter::Definitions);
333    }
334
335    #[test]
336    fn parsed_op_with_only_condition_is_readonly() {
337        let mut op = empty_op("x");
338        op.condition = Some("ok".into());
339        assert_eq!(op.polydat_matter(), PolydatMatter::Readonly);
340    }
341
342    // ── ScenarioNode ─────────────────────────────────────
343
344    // ── WorkloadPhase ───────────────────────────────────
345
346    #[test]
347    fn workload_phase_with_phase_bindings_is_definitions() {
348        let phase = WorkloadPhase {
349            cycles: None,
350            concurrency: None,
351            rate: None,
352            adapter: None,
353            errors: None,
354            tags: None,
355            ops: vec![],
356            for_each: None,
357            loop_scope: None,
358            iter_scope: None,
359            checkpoint: None,
360            status_metrics: vec![],
361            metrics: Default::default(),
362            bindings: BindingsDef::PolydatSource("k := 5".into()),
363            poll: None,
364            ..Default::default()
365        };
366        assert_eq!(phase.polydat_matter(), PolydatMatter::Definitions);
367    }
368
369    #[test]
370    fn workload_phase_with_for_each_is_definitions() {
371        let phase = WorkloadPhase {
372            cycles: None,
373            concurrency: None,
374            rate: None,
375            adapter: None,
376            errors: None,
377            tags: None,
378            ops: vec![],
379            for_each: Some("k in 1,2,3".into()),
380            loop_scope: None,
381            iter_scope: None,
382            checkpoint: None,
383            status_metrics: vec![],
384            metrics: Default::default(),
385            bindings: BindingsDef::default(),
386            poll: None,
387            ..Default::default()
388        };
389        assert_eq!(phase.polydat_matter(), PolydatMatter::Definitions);
390    }
391
392    #[test]
393    fn workload_phase_with_metrics_is_definitions() {
394        // A phase-level `metrics:` block synthesises
395        // `volatile __metric_<name> := <value>` (+ the injected
396        // `phase_start` extern) onto the phase kernel, so the phase
397        // needs its own scope kernel even with no `bindings:`.
398        let mut metrics = std::collections::HashMap::new();
399        metrics.insert(
400            "time_to_index".to_string(),
401            crate::model::MetricSpec {
402                value: "current_epoch_millis() - phase_start".into(),
403                family: None,
404                kind: None,
405                unit: None,
406                format: None,
407                cell: Default::default(),
408            },
409        );
410        let phase = WorkloadPhase {
411            cycles: None,
412            concurrency: None,
413            rate: None,
414            adapter: None,
415            errors: None,
416            tags: None,
417            ops: vec![],
418            for_each: None,
419            loop_scope: None,
420            iter_scope: None,
421            checkpoint: None,
422            status_metrics: vec![],
423            metrics,
424            bindings: BindingsDef::default(),
425            poll: None,
426            ..Default::default()
427        };
428        assert_eq!(phase.polydat_matter(), PolydatMatter::Definitions);
429    }
430
431    #[test]
432    fn workload_phase_bare_is_none() {
433        let phase = WorkloadPhase {
434            cycles: None,
435            concurrency: None,
436            rate: None,
437            adapter: None,
438            errors: None,
439            tags: None,
440            ops: vec![],
441            for_each: None,
442            loop_scope: None,
443            iter_scope: None,
444            checkpoint: None,
445            status_metrics: vec![],
446            metrics: Default::default(),
447            bindings: BindingsDef::default(),
448            poll: None,
449            ..Default::default()
450        };
451        assert_eq!(phase.polydat_matter(), PolydatMatter::None);
452    }
453
454    #[test]
455    fn workload_phase_cycles_param_ref_is_readonly() {
456        let phase = WorkloadPhase {
457            cycles: Some("{train_count}".into()),
458            concurrency: None,
459            rate: None,
460            adapter: None,
461            errors: None,
462            tags: None,
463            ops: vec![],
464            for_each: None,
465            loop_scope: None,
466            iter_scope: None,
467            checkpoint: None,
468            status_metrics: vec![],
469            metrics: Default::default(),
470            bindings: BindingsDef::default(),
471            poll: None,
472            ..Default::default()
473        };
474        assert_eq!(phase.polydat_matter(), PolydatMatter::Readonly);
475    }
476
477    // ── ScenarioNode ─────────────────────────────────────
478
479    #[test]
480    fn scenario_node_phase_is_none() {
481        let node = ScenarioNode::Phase("p".into());
482        assert_eq!(node.polydat_matter(), PolydatMatter::None);
483    }
484
485    #[test]
486    fn scenario_node_do_while_is_definitions() {
487        let node = ScenarioNode::DoWhile {
488            condition: "ok".into(),
489            counter: None,
490            children: vec![],
491        };
492        assert_eq!(node.polydat_matter(), PolydatMatter::Definitions);
493    }
494}