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 // (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}