Skip to main content

polydat_grammar/comprehension/
metadata.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Metadata algebra — spec §10.7.
5//!
6//! Every well-formed comprehension AST node carries a four-field
7//! [`Metadata`] bundle computed bottom-up from its children's
8//! metadata and its own scalar parameters. The bundle is a
9//! monoid: propagation composes under composition, and every
10//! field is either a closed enum (capability bit) or a
11//! closed-form numeric/symbolic descriptor.
12//!
13//! This module owns:
14//!
15//! - [`Metadata`] — the four-field bundle.
16//! - [`IndexFn`] — closed-form addressing schemes (six variants
17//!   covering cartesian, zip Strict/Truncate, zip Cycle, union,
18//!   continuous, hybrid).
19//! - [`NaturalOrder`] — how a node enumerates by default.
20//! - [`Materialization`] — streaming or sized-barrier
21//!   classification (spec §6.2).
22//! - [`Comprehension::metadata`] — propagation entry point.
23//!
24//! The propagation rules are total, constant-time per node, and
25//! cannot fail. Dependent-source cartesians produce
26//! `index_addressable = None`; this is the **only** place
27//! metadata propagation consults child-internal information
28//! beyond the published bundles — and it does so at the
29//! cartesian node, by walking the children's source expressions
30//! for back-references to earlier-axis names.
31
32use serde::{Deserialize, Serialize};
33
34use super::ast::Comprehension;
35use super::cardinality::{CardinalityClass, Hybrid, Interval, ProductMeasure};
36use super::source::Source;
37use super::strategy::{StrategyName, ZipMode};
38
39/// The metadata bundle carried by every well-formed AST node.
40///
41/// Computed bottom-up; never mutated after propagation. Each
42/// field is a closed enum or a closed-form descriptor — no
43/// callbacks, no fail-able analyses.
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct Metadata {
46    /// Cardinality class per spec §6.1.
47    pub cardinality: CardinalityClass,
48
49    /// Closed-form bijection from `0..|c|` to the node's
50    /// dispensed tuples. `None` when the node has no
51    /// addressable index space (raw filter output, dependent
52    /// cartesian, non-Lex order output at the AST level).
53    pub index_addressable: Option<IndexFn>,
54
55    /// How this node enumerates by default.
56    pub natural_order: NaturalOrder,
57
58    /// Streaming-vs-barrier classification per spec §6.2.
59    pub materialization: Materialization,
60}
61
62/// Closed-form addressing schemes — spec §10.7.1.
63///
64/// Six variants. Each describes the bijection from a
65/// `0..cardinality` index range to the node's tuple shape.
66/// `Continuous` and `Hybrid` carry the cardinality's
67/// interval+measure descriptors directly so the R2 push-down
68/// rules (Phase 6) can dispatch on them without recomputing.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70#[serde(tag = "kind", rename_all = "snake_case")]
71pub enum IndexFn {
72    /// Discrete cartesian. `axis_sizes[i]` is the i-th axis's
73    /// element count. Multi-index `(i₀, i₁, …)` maps to the
74    /// per-axis tuple at those positions.
75    Lattice {
76        /// Element count per axis.
77        axis_sizes: Vec<u64>,
78    },
79
80    /// Zip Strict / Truncate. One index `i ∈ 0..length` maps
81    /// to the per-child tuple at position i.
82    Lockstep {
83        /// The common length.
84        length: u64,
85    },
86
87    /// Zip Cycle. Modular addressing — index `i` maps to each
88    /// child at `i mod child.cardinality`. At least one child
89    /// must be bounded (the cycling target).
90    Modular {
91        /// Element count per child.
92        axis_sizes: Vec<u64>,
93    },
94
95    /// Union of index-addressable children. Index `i ∈
96    /// 0..Σsegment_sizes` maps to segment k where k is the
97    /// smallest such that `Σ₀^k segment_sizes > i`, position
98    /// `i - Σ₀^{k-1} segment_sizes` within that segment.
99    Concatenation {
100        /// Element count per segment, in order.
101        segment_sizes: Vec<u64>,
102    },
103
104    /// Continuous K-D box. Strategy push-down rules (Halton /
105    /// Sobol / Lhs / Extrema on Continuous) draw from this
106    /// directly; the discrete-to-continuous mapping is
107    /// strategy-specific.
108    Continuous {
109        /// The interval of each axis.
110        intervals: Vec<Interval>,
111        /// The measure drawn from.
112        measure: ProductMeasure,
113    },
114
115    /// Mixed discrete × continuous cartesian. Discrete axes get
116    /// integer indexing; continuous axes get measure-weighted
117    /// sampling. Strategy push-down dispatches per-axis.
118    Hybrid {
119        /// Element count per discrete axis.
120        discrete_axes: Vec<u64>,
121        /// The interval of each continuous axis.
122        continuous_axes: Vec<Interval>,
123        /// The measure over the continuous axes.
124        measure: ProductMeasure,
125    },
126}
127
128impl IndexFn {
129    /// `true` if this index function carries any continuous
130    /// axis. Used by per-strategy V4 checks to reject
131    /// strategies that don't accept continuous inputs.
132    pub fn has_continuous_axis(&self) -> bool {
133        matches!(self, IndexFn::Continuous { .. } | IndexFn::Hybrid { .. })
134    }
135
136    /// `true` if this index function is a multi-axis Lattice
137    /// (discrete cartesian with ≥2 axes). Required by
138    /// lattice-geometric strategies (Extrema / Shells /
139    /// Diagonal / Antidiagonal) for non-degenerate behavior.
140    pub fn is_multi_axis_lattice(&self) -> bool {
141        matches!(self, IndexFn::Lattice { axis_sizes } if axis_sizes.len() >= 2)
142    }
143}
144
145/// Natural enumeration order — spec §10.7.1.
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147#[serde(tag = "kind", rename_all = "snake_case")]
148pub enum NaturalOrder {
149    /// Lex order — rightmost axis varies fastest. Produced by
150    /// cartesian, single-axis clause, and `order(_, Lex, _)`.
151    Lex,
152
153    /// Lockstep — zip's natural order. One tuple per i, all
154    /// children at position i.
155    Lockstep,
156
157    /// Sequential — union's natural order. Drain child 0,
158    /// then child 1, etc.
159    Sequential,
160
161    /// Strategy-driven — produced by `order(_, non-Lex, _)`.
162    /// The wrapped strategy determines the emission order.
163    Strategy(StrategyName),
164
165    /// Pending — continuous source not yet wrapped by a
166    /// sampling order. V8 requires resolution before dispense.
167    PendingSampling,
168}
169
170/// Streaming-vs-barrier classification per spec §6.2.
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172#[serde(tag = "kind", rename_all = "snake_case")]
173pub enum Materialization {
174    /// O(operator-local state) per pull; no input materialized.
175    Streaming,
176
177    /// Holds a finite working set; size declared at compile
178    /// time. The two natural barriers per spec §6.3:
179    /// `zip(Cycle)` shorter children + non-Lex `order`.
180    BoundedBarrier {
181        /// Tuples the barrier holds at most.
182        working_set_size: u64,
183    },
184
185    /// Working set is unbounded. Always V6-rejected per spec
186    /// §5; this variant exists for representational
187    /// completeness but should never propagate through to a
188    /// valid AST's metadata.
189    UnboundedBarrier,
190}
191
192impl Comprehension {
193    /// Compute this node's metadata bundle per spec §10.7.2.
194    ///
195    /// Bottom-up: every child's metadata is computed first,
196    /// then this node's. Constant-time per node above the
197    /// child cost. Total — never fails, never partial.
198    ///
199    /// For non-leaf nodes the metadata is recomputed on every
200    /// call (no caching at this layer); consumers that need
201    /// memoization should wrap externally. This is fine
202    /// because the propagation cost is O(N) total nodes and
203    /// the optimizer (Phase 6) re-propagates after each
204    /// rewrite anyway.
205    pub fn metadata(&self) -> Metadata {
206        match self {
207            Comprehension::Clause { source, .. } => clause_metadata(source),
208            Comprehension::Cartesian { children } => cartesian_metadata(children),
209            Comprehension::Zip { children, mode } => zip_metadata(children, *mode),
210            Comprehension::Union { children } => union_metadata(children),
211            Comprehension::Filter { child, .. } => filter_metadata(child),
212            Comprehension::Order {
213                child,
214                strategy,
215                truncation,
216            } => order_metadata(child, *strategy, *truncation),
217        }
218    }
219}
220
221fn clause_metadata(source: &Source) -> Metadata {
222    let cardinality = source.cardinality();
223    let (index_addressable, natural_order) = match &cardinality {
224        CardinalityClass::Bounded(n) => (
225            Some(IndexFn::Lattice {
226                axis_sizes: vec![*n],
227            }),
228            NaturalOrder::Lex,
229        ),
230        CardinalityClass::Continuous { intervals, measure } => (
231            Some(IndexFn::Continuous {
232                intervals: intervals.clone(),
233                measure: measure.clone(),
234            }),
235            NaturalOrder::PendingSampling,
236        ),
237        // BoundedAtMost / Unbounded / ContinuousAtMost — no
238        // closed-form addressing function exists.
239        _ => (None, NaturalOrder::Lex),
240    };
241    Metadata {
242        cardinality,
243        index_addressable,
244        natural_order,
245        materialization: Materialization::Streaming,
246    }
247}
248
249fn cartesian_metadata(children: &[Comprehension]) -> Metadata {
250    // First detect dependent sources: any child whose source
251    // expression references an earlier child's coordinate name.
252    // Dependent → index_addressable = None.
253    let dependent = detect_dependent_sources(children);
254
255    let child_meta: Vec<Metadata> = children.iter().map(|c| c.metadata()).collect();
256    let cardinality = combine_cartesian_cardinality(&child_meta);
257
258    let index_addressable = if dependent {
259        None
260    } else {
261        combine_cartesian_index_fn(&child_meta)
262    };
263
264    let natural_order = if matches!(
265        cardinality,
266        CardinalityClass::Continuous { .. } | CardinalityClass::Hybrid(_)
267    ) {
268        NaturalOrder::PendingSampling
269    } else {
270        NaturalOrder::Lex
271    };
272
273    Metadata {
274        cardinality,
275        index_addressable,
276        natural_order,
277        materialization: Materialization::Streaming,
278    }
279}
280
281fn zip_metadata(children: &[Comprehension], mode: ZipMode) -> Metadata {
282    let child_meta: Vec<Metadata> = children.iter().map(|c| c.metadata()).collect();
283    let cardinality = combine_zip_cardinality(&child_meta, mode);
284    let index_addressable = combine_zip_index_fn(&child_meta, mode);
285
286    let materialization = match mode {
287        ZipMode::Strict | ZipMode::Truncate => Materialization::Streaming,
288        ZipMode::Cycle => {
289            // Shorter children's cardinalities sum into the
290            // barrier working set (each non-longest child must
291            // replay).
292            let cards: Vec<u64> = child_meta
293                .iter()
294                .filter_map(|m| match &m.cardinality {
295                    CardinalityClass::Bounded(n) | CardinalityClass::BoundedAtMost(n) => Some(*n),
296                    _ => None,
297                })
298                .collect();
299            if cards.is_empty() {
300                Materialization::Streaming
301            } else {
302                let max = cards.iter().copied().max().unwrap_or(0);
303                let sum_non_longest: u64 = cards.iter().filter(|n| **n != max).sum();
304                Materialization::BoundedBarrier {
305                    working_set_size: sum_non_longest,
306                }
307            }
308        }
309    };
310
311    Metadata {
312        cardinality,
313        index_addressable,
314        natural_order: NaturalOrder::Lockstep,
315        materialization,
316    }
317}
318
319fn union_metadata(children: &[Comprehension]) -> Metadata {
320    let child_meta: Vec<Metadata> = children.iter().map(|c| c.metadata()).collect();
321    let cardinality = combine_union_cardinality(&child_meta);
322    let index_addressable = combine_union_index_fn(&child_meta);
323    Metadata {
324        cardinality,
325        index_addressable,
326        natural_order: NaturalOrder::Sequential,
327        materialization: Materialization::Streaming,
328    }
329}
330
331fn filter_metadata(child: &Comprehension) -> Metadata {
332    let child_meta = child.metadata();
333    let cardinality = match &child_meta.cardinality {
334        CardinalityClass::Bounded(n) | CardinalityClass::BoundedAtMost(n) => {
335            CardinalityClass::BoundedAtMost(*n)
336        }
337        CardinalityClass::Unbounded => CardinalityClass::Unbounded,
338        CardinalityClass::Continuous { intervals, measure }
339        | CardinalityClass::ContinuousAtMost {
340            intervals,
341            measure_at_most: measure,
342        } => CardinalityClass::ContinuousAtMost {
343            intervals: intervals.clone(),
344            measure_at_most: measure.clone(),
345        },
346        CardinalityClass::Hybrid(h) => CardinalityClass::Hybrid(h.clone()),
347    };
348    Metadata {
349        cardinality,
350        index_addressable: None, // filter destroys the bijection
351        natural_order: child_meta.natural_order,
352        materialization: child_meta.materialization,
353    }
354}
355
356fn order_metadata(
357    child: &Comprehension,
358    strategy: StrategyName,
359    truncation: Option<u64>,
360) -> Metadata {
361    let child_meta = child.metadata();
362    let cardinality = match (&child_meta.cardinality, truncation) {
363        // Continuous + sampling + Some(n) → Bounded(n) (V8 discharge).
364        (CardinalityClass::Continuous { .. }, Some(n))
365        | (CardinalityClass::ContinuousAtMost { .. }, Some(n))
366        | (CardinalityClass::Hybrid(_), Some(n))
367            if !matches!(strategy, StrategyName::Lex) =>
368        {
369            CardinalityClass::Bounded(n)
370        }
371        // Discrete + truncation: min of (child, n).
372        (CardinalityClass::Bounded(child_n), Some(n)) => {
373            CardinalityClass::Bounded((*child_n).min(n))
374        }
375        (CardinalityClass::BoundedAtMost(child_n), Some(n)) => {
376            CardinalityClass::BoundedAtMost((*child_n).min(n))
377        }
378        (_, Some(n)) => CardinalityClass::Bounded(n), // unbounded + Some(n) → Bounded(n)
379        // No truncation: inherit child's cardinality.
380        (c, None) => c.clone(),
381    };
382
383    let (index_addressable, natural_order, materialization) = match strategy {
384        StrategyName::Lex => (
385            child_meta.index_addressable, // inherit through Lex
386            NaturalOrder::Lex,
387            child_meta.materialization, // counter wrapper at most
388        ),
389        non_lex => {
390            // R2 (Phase 6) rewrites this into an indexed_order
391            // IR opcode; AST-level metadata stops here.
392            let working_set_size =
393                strategy_working_set(non_lex, &child_meta.index_addressable, truncation);
394            (
395                None,
396                NaturalOrder::Strategy(non_lex),
397                Materialization::BoundedBarrier { working_set_size },
398            )
399        }
400    };
401
402    Metadata {
403        cardinality,
404        index_addressable,
405        natural_order,
406        materialization,
407    }
408}
409
410// ---- cardinality combinators ----
411
412fn combine_cartesian_cardinality(children: &[Metadata]) -> CardinalityClass {
413    let mut has_continuous = false;
414    let mut has_discrete = false;
415    let mut has_unbounded = false;
416    let mut product: u64 = 1;
417    let mut overflow = false;
418    let mut discrete_axes: Vec<u64> = Vec::new();
419    let mut continuous_intervals: Vec<Interval> = Vec::new();
420    let mut continuous_measures: Vec<ProductMeasure> = Vec::new();
421
422    for m in children {
423        match &m.cardinality {
424            CardinalityClass::Bounded(n) => {
425                has_discrete = true;
426                discrete_axes.push(*n);
427                product = product.checked_mul(*n).unwrap_or_else(|| {
428                    overflow = true;
429                    u64::MAX
430                });
431            }
432            CardinalityClass::BoundedAtMost(n) => {
433                has_discrete = true;
434                discrete_axes.push(*n); // upper bound
435                product = product.checked_mul(*n).unwrap_or_else(|| {
436                    overflow = true;
437                    u64::MAX
438                });
439            }
440            CardinalityClass::Unbounded => {
441                has_unbounded = true;
442                has_discrete = true;
443                discrete_axes.push(0);
444            }
445            CardinalityClass::Continuous { intervals, measure }
446            | CardinalityClass::ContinuousAtMost {
447                intervals,
448                measure_at_most: measure,
449            } => {
450                has_continuous = true;
451                continuous_intervals.extend(intervals.iter().cloned());
452                continuous_measures.push(measure.clone());
453            }
454            CardinalityClass::Hybrid(h) => {
455                has_continuous = true;
456                has_discrete = true;
457                discrete_axes.extend(h.discrete_axes.iter().copied());
458                continuous_intervals.extend(h.continuous_axes.iter().cloned());
459                continuous_measures.push(h.measure.clone());
460            }
461        }
462    }
463
464    let _ = overflow; // discard; saturating product is the policy
465
466    if has_continuous && has_discrete {
467        CardinalityClass::Hybrid(Hybrid {
468            discrete_axes,
469            continuous_axes: continuous_intervals,
470            measure: simplify_measures(continuous_measures),
471        })
472    } else if has_continuous {
473        CardinalityClass::Continuous {
474            intervals: continuous_intervals,
475            measure: simplify_measures(continuous_measures),
476        }
477    } else if has_unbounded {
478        CardinalityClass::Unbounded
479    } else {
480        CardinalityClass::Bounded(product)
481    }
482}
483
484fn combine_cartesian_index_fn(children: &[Metadata]) -> Option<IndexFn> {
485    // All children must be addressable for the cartesian to be.
486    let all_addressable = children.iter().all(|m| m.index_addressable.is_some());
487    if !all_addressable {
488        return None;
489    }
490
491    let mut all_discrete = true;
492    let mut all_continuous = true;
493    let mut discrete_axes: Vec<u64> = Vec::new();
494    let mut continuous_intervals: Vec<Interval> = Vec::new();
495    let mut continuous_measures: Vec<ProductMeasure> = Vec::new();
496
497    for m in children {
498        match m.index_addressable.as_ref().unwrap() {
499            IndexFn::Lattice { axis_sizes } => {
500                all_continuous = false;
501                discrete_axes.extend(axis_sizes.iter().copied());
502            }
503            IndexFn::Continuous { intervals, measure } => {
504                all_discrete = false;
505                continuous_intervals.extend(intervals.iter().cloned());
506                continuous_measures.push(measure.clone());
507            }
508            IndexFn::Hybrid {
509                discrete_axes: d,
510                continuous_axes: c,
511                measure,
512            } => {
513                all_discrete = false;
514                all_continuous = false;
515                discrete_axes.extend(d.iter().copied());
516                continuous_intervals.extend(c.iter().cloned());
517                continuous_measures.push(measure.clone());
518            }
519            // Lockstep / Modular / Concatenation — these don't
520            // combine as cartesian axes (they're 1-D index
521            // spaces of their own); cartesian-of-zip / cartesian-
522            // of-union would need a richer addressing scheme.
523            // For now, fall back to None.
524            IndexFn::Lockstep { .. } | IndexFn::Modular { .. } | IndexFn::Concatenation { .. } => {
525                return None;
526            }
527        }
528    }
529
530    if all_discrete {
531        Some(IndexFn::Lattice {
532            axis_sizes: discrete_axes,
533        })
534    } else if all_continuous {
535        Some(IndexFn::Continuous {
536            intervals: continuous_intervals,
537            measure: simplify_measures(continuous_measures),
538        })
539    } else {
540        Some(IndexFn::Hybrid {
541            discrete_axes,
542            continuous_axes: continuous_intervals,
543            measure: simplify_measures(continuous_measures),
544        })
545    }
546}
547
548fn combine_zip_cardinality(children: &[Metadata], mode: ZipMode) -> CardinalityClass {
549    // V7 should have rejected mixed-class / continuous; here we
550    // assume discrete children.
551    let counts: Vec<Option<u64>> = children
552        .iter()
553        .map(|m| match &m.cardinality {
554            CardinalityClass::Bounded(n) | CardinalityClass::BoundedAtMost(n) => Some(*n),
555            CardinalityClass::Unbounded => None,
556            // Continuous / Hybrid here would be a V7 failure
557            // that slipped through; treat as Unbounded for
558            // metadata purposes.
559            _ => None,
560        })
561        .collect();
562
563    match mode {
564        ZipMode::Strict => {
565            // V7 should have caught mismatch. Use any bounded child's count.
566            counts
567                .iter()
568                .find_map(|c| *c)
569                .map(CardinalityClass::Bounded)
570                .unwrap_or(CardinalityClass::Unbounded)
571        }
572        ZipMode::Truncate => {
573            let bounded: Vec<u64> = counts.iter().filter_map(|c| *c).collect();
574            if bounded.is_empty() {
575                CardinalityClass::Unbounded
576            } else {
577                CardinalityClass::Bounded(*bounded.iter().min().unwrap())
578            }
579        }
580        ZipMode::Cycle => {
581            let bounded: Vec<u64> = counts.iter().filter_map(|c| *c).collect();
582            if counts.iter().any(Option::is_none) {
583                CardinalityClass::Unbounded
584            } else if let Some(max) = bounded.iter().max() {
585                CardinalityClass::Bounded(*max)
586            } else {
587                CardinalityClass::Bounded(0)
588            }
589        }
590    }
591}
592
593fn combine_zip_index_fn(children: &[Metadata], mode: ZipMode) -> Option<IndexFn> {
594    let all_addressable = children.iter().all(|m| m.index_addressable.is_some());
595    if !all_addressable {
596        return None;
597    }
598    let counts: Vec<u64> = children
599        .iter()
600        .filter_map(|m| match &m.cardinality {
601            CardinalityClass::Bounded(n) | CardinalityClass::BoundedAtMost(n) => Some(*n),
602            _ => None,
603        })
604        .collect();
605    if counts.len() != children.len() {
606        return None;
607    }
608    match mode {
609        ZipMode::Strict | ZipMode::Truncate => {
610            let length = match mode {
611                ZipMode::Strict => counts[0],
612                ZipMode::Truncate => *counts.iter().min().unwrap(),
613                ZipMode::Cycle => unreachable!(),
614            };
615            Some(IndexFn::Lockstep { length })
616        }
617        ZipMode::Cycle => Some(IndexFn::Modular { axis_sizes: counts }),
618    }
619}
620
621fn combine_union_cardinality(children: &[Metadata]) -> CardinalityClass {
622    let mut sum: u64 = 0;
623    let mut any_unbounded = false;
624    let mut any_atmost = false;
625    for m in children {
626        match &m.cardinality {
627            CardinalityClass::Bounded(n) => {
628                sum = sum.saturating_add(*n);
629            }
630            CardinalityClass::BoundedAtMost(n) => {
631                sum = sum.saturating_add(*n);
632                any_atmost = true;
633            }
634            CardinalityClass::Unbounded => {
635                any_unbounded = true;
636            }
637            // V9 should have caught continuous-in-union.
638            _ => any_unbounded = true,
639        }
640    }
641    if any_unbounded {
642        CardinalityClass::Unbounded
643    } else if any_atmost {
644        CardinalityClass::BoundedAtMost(sum)
645    } else {
646        CardinalityClass::Bounded(sum)
647    }
648}
649
650fn combine_union_index_fn(children: &[Metadata]) -> Option<IndexFn> {
651    let all_addressable = children.iter().all(|m| m.index_addressable.is_some());
652    if !all_addressable {
653        return None;
654    }
655    let segment_sizes: Vec<u64> = children
656        .iter()
657        .filter_map(|m| match &m.cardinality {
658            CardinalityClass::Bounded(n) | CardinalityClass::BoundedAtMost(n) => Some(*n),
659            _ => None,
660        })
661        .collect();
662    if segment_sizes.len() != children.len() {
663        return None;
664    }
665    Some(IndexFn::Concatenation { segment_sizes })
666}
667
668// ---- supporting helpers ----
669
670fn simplify_measures(measures: Vec<ProductMeasure>) -> ProductMeasure {
671    match measures.len() {
672        0 => ProductMeasure::Uniform,
673        1 => measures.into_iter().next().unwrap(),
674        _ => ProductMeasure::Product(measures),
675    }
676}
677
678/// Strategy-specific working-set size for use as
679/// `BoundedBarrier.working_set_size`. Pre-R2, the naïve form
680/// uses the input cardinality; with R2 push-down, the size
681/// shrinks to the strategy's closed-form minimum. The metadata
682/// here records the **R2-realized** size (the size the
683/// optimizer will achieve), so consumers reading metadata see
684/// the post-optimization budget.
685fn strategy_working_set(
686    strategy: StrategyName,
687    input: &Option<IndexFn>,
688    truncation: Option<u64>,
689) -> u64 {
690    match (strategy, input, truncation) {
691        // Halton / Sobol / Shuffle over an index-addressable
692        // input + truncation: O(n) draws.
693        (StrategyName::Halton, Some(_), Some(n))
694        | (StrategyName::Sobol, Some(_), Some(n))
695        | (StrategyName::Shuffle, Some(_), Some(n))
696        | (StrategyName::ReverseLex, Some(_), Some(n)) => n,
697        // Lhs: O(n * dim).
698        (StrategyName::Lhs, Some(idx), Some(n)) => {
699            let dim = lattice_dim(idx).max(1);
700            n.saturating_mul(dim as u64)
701        }
702        // Extrema (SRD-18d §214): `/k` selects the first k *strata*
703        // (interior count 0..k-1), not k tuples — the output is
704        // `≥ 2^dim` corners for k≥1 and grows to the full space. The
705        // materialize step buffers the whole input regardless, so the
706        // safe working-set bound is the input cardinality. (A tight
707        // first-k-strata sum would need per-axis interior sizes;
708        // deferred — over-reporting here is safe, under-reporting is
709        // not.)
710        (StrategyName::Extrema, Some(idx), Some(_k)) => index_fn_cardinality(idx),
711        // Shells / Diagonal / Antidiagonal: per-emitted O(N).
712        (StrategyName::Shells, Some(_), Some(n))
713        | (StrategyName::Diagonal, Some(_), Some(n))
714        | (StrategyName::Antidiagonal, Some(_), Some(n)) => n,
715        // No truncation: fall back to the input's cardinality.
716        (_, Some(idx), None) => index_fn_cardinality(idx),
717        // No addressable input: we can't compute a closed form;
718        // use the naïve "input cardinality" placeholder so the
719        // metadata still has a number (consumers should treat
720        // this as a conservative upper bound).
721        (_, None, Some(n)) => n,
722        (_, None, None) => 0,
723        // Lex with truncation over addressable input — counter
724        // wrapper, working set equals output size.
725        (StrategyName::Lex, Some(_), Some(n)) => n,
726    }
727}
728
729fn lattice_dim(idx: &IndexFn) -> usize {
730    match idx {
731        IndexFn::Lattice { axis_sizes } => axis_sizes.len(),
732        IndexFn::Continuous { intervals, .. } => intervals.len(),
733        IndexFn::Hybrid {
734            discrete_axes,
735            continuous_axes,
736            ..
737        } => discrete_axes.len() + continuous_axes.len(),
738        IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => 1,
739        IndexFn::Concatenation { segment_sizes } => segment_sizes.len(),
740    }
741}
742
743fn index_fn_cardinality(idx: &IndexFn) -> u64 {
744    match idx {
745        IndexFn::Lattice { axis_sizes } => axis_sizes
746            .iter()
747            .copied()
748            .fold(1u64, |a, b| a.saturating_mul(b)),
749        IndexFn::Lockstep { length } => *length,
750        IndexFn::Modular { axis_sizes } => axis_sizes.iter().copied().max().unwrap_or(0),
751        IndexFn::Concatenation { segment_sizes } => segment_sizes
752            .iter()
753            .copied()
754            .fold(0u64, |a, b| a.saturating_add(b)),
755        // Continuous index has no integer cardinality.
756        IndexFn::Continuous { .. } | IndexFn::Hybrid { .. } => 0,
757    }
758}
759
760/// Walk children's source expressions for back-references to
761/// earlier-axis coordinate names. Used by cartesian metadata
762/// propagation to detect dependent sources per spec §3.2.
763fn detect_dependent_sources(children: &[Comprehension]) -> bool {
764    let mut prior_names: Vec<String> = Vec::new();
765    for child in children {
766        // First check if the child references any prior name in
767        // its source(s).
768        for name in collect_source_name_references(child) {
769            if prior_names.contains(&name) {
770                return true;
771            }
772        }
773        // Then add this child's coordinates to the prior set.
774        for n in child.coordinate_names() {
775            if !prior_names.contains(&n) {
776                prior_names.push(n);
777            }
778        }
779    }
780    false
781}
782
783/// Extract `{name}` interpolation references from source
784/// expressions in a comprehension subtree. Sources that carry
785/// raw strings (`Generator`, `WorkloadParamList`) are walked;
786/// `Literal`, `IntRange`, `ContinuousInterval`, `Distribution`
787/// contain no string references.
788fn collect_source_name_references(c: &Comprehension) -> Vec<String> {
789    let mut out = Vec::new();
790    walk_source_refs(c, &mut out);
791    out
792}
793
794fn walk_source_refs(c: &Comprehension, out: &mut Vec<String>) {
795    match c {
796        Comprehension::Clause { source, .. } => {
797            extract_source_refs(source, out);
798        }
799        Comprehension::Cartesian { children }
800        | Comprehension::Zip { children, .. }
801        | Comprehension::Union { children } => {
802            for c in children {
803                walk_source_refs(c, out);
804            }
805        }
806        Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
807            walk_source_refs(child, out);
808        }
809    }
810}
811
812fn extract_source_refs(source: &Source, out: &mut Vec<String>) {
813    let s = match source {
814        Source::Generator { expr, .. } => expr.as_str(),
815        Source::WorkloadParamList { name, .. } => name.as_str(),
816        _ => return,
817    };
818    let bytes = s.as_bytes();
819    let mut i = 0;
820    while i < bytes.len() {
821        if bytes[i] == b'{'
822            && let Some(close) = s[i + 1..].find('}')
823        {
824            let name = s[i + 1..i + 1 + close].trim();
825            if !name.is_empty()
826                && name.chars().all(|c| c.is_alphanumeric() || c == '_')
827                && !out.contains(&name.to_string())
828            {
829                out.push(name.to_string());
830            }
831            i += close + 2;
832            continue;
833        }
834        i += 1;
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841    use crate::comprehension::source::{LiteralValue, Source};
842
843    fn clause(name: &str, vs: &[i64]) -> Comprehension {
844        Comprehension::clause(
845            name,
846            Source::Literal {
847                values: vs.iter().map(|n| LiteralValue::Int(*n)).collect(),
848            },
849        )
850    }
851
852    fn continuous_clause(name: &str) -> Comprehension {
853        Comprehension::clause(
854            name,
855            Source::ContinuousInterval {
856                interval: Interval::closed(0.0, 1.0),
857                measure: ProductMeasure::Uniform,
858            },
859        )
860    }
861
862    #[test]
863    fn clause_metadata_for_bounded_source() {
864        let m = clause("k", &[1, 2, 3]).metadata();
865        assert_eq!(m.cardinality, CardinalityClass::Bounded(3));
866        assert_eq!(
867            m.index_addressable,
868            Some(IndexFn::Lattice {
869                axis_sizes: vec![3]
870            })
871        );
872        assert_eq!(m.natural_order, NaturalOrder::Lex);
873        assert_eq!(m.materialization, Materialization::Streaming);
874    }
875
876    #[test]
877    fn clause_metadata_for_continuous_source() {
878        let m = continuous_clause("alpha").metadata();
879        assert!(matches!(m.cardinality, CardinalityClass::Continuous { .. }));
880        assert!(matches!(
881            m.index_addressable,
882            Some(IndexFn::Continuous { .. })
883        ));
884        assert_eq!(m.natural_order, NaturalOrder::PendingSampling);
885        assert_eq!(m.materialization, Materialization::Streaming);
886    }
887
888    #[test]
889    fn cartesian_metadata_combines_lattice_axes() {
890        let c =
891            Comprehension::cartesian(vec![clause("k", &[1, 2]), clause("limit", &[10, 20, 30])]);
892        let m = c.metadata();
893        assert_eq!(m.cardinality, CardinalityClass::Bounded(6));
894        assert_eq!(
895            m.index_addressable,
896            Some(IndexFn::Lattice {
897                axis_sizes: vec![2, 3]
898            })
899        );
900        assert_eq!(m.natural_order, NaturalOrder::Lex);
901    }
902
903    #[test]
904    fn cartesian_metadata_for_hybrid() {
905        let c =
906            Comprehension::cartesian(vec![clause("k", &[1, 2, 3, 4]), continuous_clause("theta")]);
907        let m = c.metadata();
908        match m.cardinality {
909            CardinalityClass::Hybrid(h) => {
910                assert_eq!(h.discrete_axes, vec![4]);
911                assert_eq!(h.continuous_axes.len(), 1);
912            }
913            other => panic!("expected Hybrid, got {other:?}"),
914        }
915        assert!(matches!(m.index_addressable, Some(IndexFn::Hybrid { .. })));
916        assert_eq!(m.natural_order, NaturalOrder::PendingSampling);
917    }
918
919    #[test]
920    fn dependent_cartesian_produces_none_addressable() {
921        // clause replicas references {k} from the prior clause.
922        let dependent = Comprehension::cartesian(vec![
923            clause("k", &[1, 2, 3]),
924            Comprehension::clause(
925                "replicas",
926                Source::Generator {
927                    expr: "range(0, 2 * {k})".into(),
928                    cardinality_hint: Some(6),
929                },
930            ),
931        ]);
932        let m = dependent.metadata();
933        assert!(m.index_addressable.is_none());
934    }
935
936    #[test]
937    fn zip_strict_produces_lockstep_index_fn() {
938        let c = Comprehension::zip(
939            vec![clause("x", &[1, 2, 3]), clause("y", &[10, 20, 30])],
940            ZipMode::Strict,
941        );
942        let m = c.metadata();
943        assert_eq!(m.index_addressable, Some(IndexFn::Lockstep { length: 3 }));
944        assert_eq!(m.natural_order, NaturalOrder::Lockstep);
945        assert_eq!(m.materialization, Materialization::Streaming);
946    }
947
948    #[test]
949    fn zip_cycle_produces_modular_index_fn_and_barrier() {
950        let c = Comprehension::zip(
951            vec![clause("k", &[1, 2, 3, 4, 5]), clause("color", &[1, 2, 3])],
952            ZipMode::Cycle,
953        );
954        let m = c.metadata();
955        match m.index_addressable {
956            Some(IndexFn::Modular { axis_sizes }) => {
957                assert_eq!(axis_sizes, vec![5, 3]);
958            }
959            other => panic!("expected Modular, got {other:?}"),
960        }
961        // shorter child cardinality = 3 → barrier size 3
962        assert_eq!(
963            m.materialization,
964            Materialization::BoundedBarrier {
965                working_set_size: 3
966            }
967        );
968    }
969
970    #[test]
971    fn union_produces_concatenation_index_fn() {
972        let a = Comprehension::cartesian(vec![clause("k", &[1, 2]), clause("limit", &[10])]);
973        let b = Comprehension::cartesian(vec![clause("k", &[3, 4]), clause("limit", &[20])]);
974        let u = Comprehension::union(vec![a, b]);
975        let m = u.metadata();
976        assert_eq!(m.cardinality, CardinalityClass::Bounded(4));
977        assert_eq!(
978            m.index_addressable,
979            Some(IndexFn::Concatenation {
980                segment_sizes: vec![2, 2]
981            })
982        );
983        assert_eq!(m.natural_order, NaturalOrder::Sequential);
984    }
985
986    #[test]
987    fn filter_destroys_addressability() {
988        let inner =
989            Comprehension::cartesian(vec![clause("k", &[1, 2]), clause("limit", &[10, 20])]);
990        let filtered = Comprehension::filter(inner, "{k} > 0");
991        let m = filtered.metadata();
992        assert_eq!(m.cardinality, CardinalityClass::BoundedAtMost(4));
993        assert_eq!(m.index_addressable, None);
994    }
995
996    #[test]
997    fn lex_order_inherits_addressability() {
998        let inner =
999            Comprehension::cartesian(vec![clause("k", &[1, 2]), clause("limit", &[10, 20])]);
1000        let ordered = Comprehension::order(inner, StrategyName::Lex, Some(2));
1001        let m = ordered.metadata();
1002        assert_eq!(m.cardinality, CardinalityClass::Bounded(2));
1003        assert!(matches!(m.index_addressable, Some(IndexFn::Lattice { .. })));
1004        assert_eq!(m.natural_order, NaturalOrder::Lex);
1005    }
1006
1007    #[test]
1008    fn non_lex_order_drops_ast_level_addressability() {
1009        let inner =
1010            Comprehension::cartesian(vec![clause("k", &[1, 2]), clause("limit", &[10, 20])]);
1011        let ordered = Comprehension::order(inner, StrategyName::Halton, Some(2));
1012        let m = ordered.metadata();
1013        assert!(m.index_addressable.is_none());
1014        match m.natural_order {
1015            NaturalOrder::Strategy(StrategyName::Halton) => {}
1016            other => panic!("expected Strategy(Halton), got {other:?}"),
1017        }
1018        assert_eq!(
1019            m.materialization,
1020            Materialization::BoundedBarrier {
1021                working_set_size: 2
1022            }
1023        );
1024    }
1025
1026    #[test]
1027    fn continuous_sampling_yields_bounded_cardinality() {
1028        let inner =
1029            Comprehension::cartesian(vec![continuous_clause("alpha"), continuous_clause("beta")]);
1030        let ordered = Comprehension::order(inner, StrategyName::Halton, Some(100));
1031        let m = ordered.metadata();
1032        assert_eq!(m.cardinality, CardinalityClass::Bounded(100));
1033        assert_eq!(
1034            m.materialization,
1035            Materialization::BoundedBarrier {
1036                working_set_size: 100
1037            }
1038        );
1039    }
1040
1041    #[test]
1042    fn metadata_propagation_is_idempotent() {
1043        let c = Comprehension::order(
1044            Comprehension::filter(
1045                Comprehension::cartesian(vec![clause("k", &[1, 2, 3]), clause("limit", &[10, 20])]),
1046                "{k} * {limit} > 5",
1047            ),
1048            StrategyName::Halton,
1049            Some(5),
1050        );
1051        let m1 = c.metadata();
1052        let m2 = c.metadata();
1053        assert_eq!(m1, m2);
1054    }
1055
1056    #[test]
1057    fn has_continuous_axis_classifier() {
1058        let lat = IndexFn::Lattice {
1059            axis_sizes: vec![3, 4],
1060        };
1061        assert!(!lat.has_continuous_axis());
1062
1063        let cont = IndexFn::Continuous {
1064            intervals: vec![Interval::closed(0.0, 1.0)],
1065            measure: ProductMeasure::Uniform,
1066        };
1067        assert!(cont.has_continuous_axis());
1068    }
1069
1070    #[test]
1071    fn multi_axis_lattice_classifier() {
1072        assert!(
1073            IndexFn::Lattice {
1074                axis_sizes: vec![3, 4]
1075            }
1076            .is_multi_axis_lattice()
1077        );
1078        assert!(
1079            !IndexFn::Lattice {
1080                axis_sizes: vec![3]
1081            }
1082            .is_multi_axis_lattice()
1083        );
1084        assert!(
1085            !IndexFn::Continuous {
1086                intervals: vec![Interval::closed(0.0, 1.0), Interval::closed(0.0, 1.0)],
1087                measure: ProductMeasure::Uniform,
1088            }
1089            .is_multi_axis_lattice()
1090        );
1091    }
1092}