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                ..
217            } => order_metadata(child, *strategy, *truncation),
218        }
219    }
220}
221
222fn clause_metadata(source: &Source) -> Metadata {
223    let cardinality = source.cardinality();
224    let (index_addressable, natural_order) = match &cardinality {
225        CardinalityClass::Bounded(n) => (
226            Some(IndexFn::Lattice {
227                axis_sizes: vec![*n],
228            }),
229            NaturalOrder::Lex,
230        ),
231        CardinalityClass::Continuous { intervals, measure } => (
232            Some(IndexFn::Continuous {
233                intervals: intervals.clone(),
234                measure: measure.clone(),
235            }),
236            NaturalOrder::PendingSampling,
237        ),
238        // BoundedAtMost / Unbounded / ContinuousAtMost — no
239        // closed-form addressing function exists.
240        _ => (None, NaturalOrder::Lex),
241    };
242    Metadata {
243        cardinality,
244        index_addressable,
245        natural_order,
246        materialization: Materialization::Streaming,
247    }
248}
249
250fn cartesian_metadata(children: &[Comprehension]) -> Metadata {
251    // First detect dependent sources: any child whose source
252    // expression references an earlier child's coordinate name.
253    // Dependent → index_addressable = None.
254    let dependent = detect_dependent_sources(children);
255
256    let child_meta: Vec<Metadata> = children.iter().map(|c| c.metadata()).collect();
257    let cardinality = combine_cartesian_cardinality(&child_meta);
258
259    let index_addressable = if dependent {
260        None
261    } else {
262        combine_cartesian_index_fn(&child_meta)
263    };
264
265    let natural_order = if matches!(
266        cardinality,
267        CardinalityClass::Continuous { .. } | CardinalityClass::Hybrid(_)
268    ) {
269        NaturalOrder::PendingSampling
270    } else {
271        NaturalOrder::Lex
272    };
273
274    Metadata {
275        cardinality,
276        index_addressable,
277        natural_order,
278        materialization: Materialization::Streaming,
279    }
280}
281
282fn zip_metadata(children: &[Comprehension], mode: ZipMode) -> Metadata {
283    let child_meta: Vec<Metadata> = children.iter().map(|c| c.metadata()).collect();
284    let cardinality = combine_zip_cardinality(&child_meta, mode);
285    let index_addressable = combine_zip_index_fn(&child_meta, mode);
286
287    let materialization = match mode {
288        ZipMode::Strict | ZipMode::Truncate => Materialization::Streaming,
289        ZipMode::Cycle => {
290            // Shorter children's cardinalities sum into the
291            // barrier working set (each non-longest child must
292            // replay).
293            let cards: Vec<u64> = child_meta
294                .iter()
295                .filter_map(|m| match &m.cardinality {
296                    CardinalityClass::Bounded(n) | CardinalityClass::BoundedAtMost(n) => Some(*n),
297                    _ => None,
298                })
299                .collect();
300            if cards.is_empty() {
301                Materialization::Streaming
302            } else {
303                let max = cards.iter().copied().max().unwrap_or(0);
304                let sum_non_longest: u64 = cards.iter().filter(|n| **n != max).sum();
305                Materialization::BoundedBarrier {
306                    working_set_size: sum_non_longest,
307                }
308            }
309        }
310    };
311
312    Metadata {
313        cardinality,
314        index_addressable,
315        natural_order: NaturalOrder::Lockstep,
316        materialization,
317    }
318}
319
320fn union_metadata(children: &[Comprehension]) -> Metadata {
321    let child_meta: Vec<Metadata> = children.iter().map(|c| c.metadata()).collect();
322    let cardinality = combine_union_cardinality(&child_meta);
323    let index_addressable = combine_union_index_fn(&child_meta);
324    Metadata {
325        cardinality,
326        index_addressable,
327        natural_order: NaturalOrder::Sequential,
328        materialization: Materialization::Streaming,
329    }
330}
331
332fn filter_metadata(child: &Comprehension) -> Metadata {
333    let child_meta = child.metadata();
334    let cardinality = match &child_meta.cardinality {
335        CardinalityClass::Bounded(n) | CardinalityClass::BoundedAtMost(n) => {
336            CardinalityClass::BoundedAtMost(*n)
337        }
338        CardinalityClass::Unbounded => CardinalityClass::Unbounded,
339        CardinalityClass::Continuous { intervals, measure }
340        | CardinalityClass::ContinuousAtMost {
341            intervals,
342            measure_at_most: measure,
343        } => CardinalityClass::ContinuousAtMost {
344            intervals: intervals.clone(),
345            measure_at_most: measure.clone(),
346        },
347        CardinalityClass::Hybrid(h) => CardinalityClass::Hybrid(h.clone()),
348    };
349    Metadata {
350        cardinality,
351        index_addressable: None, // filter destroys the bijection
352        natural_order: child_meta.natural_order,
353        materialization: child_meta.materialization,
354    }
355}
356
357fn order_metadata(
358    child: &Comprehension,
359    strategy: StrategyName,
360    truncation: Option<u64>,
361) -> Metadata {
362    let child_meta = child.metadata();
363    let cardinality = match (&child_meta.cardinality, truncation) {
364        // Continuous + sampling + Some(n) → Bounded(n) (V8 discharge).
365        (CardinalityClass::Continuous { .. }, Some(n))
366        | (CardinalityClass::ContinuousAtMost { .. }, Some(n))
367        | (CardinalityClass::Hybrid(_), Some(n))
368            if !matches!(strategy, StrategyName::Lex) =>
369        {
370            CardinalityClass::Bounded(n)
371        }
372        // Discrete + truncation: min of (child, n).
373        (CardinalityClass::Bounded(child_n), Some(n)) => {
374            CardinalityClass::Bounded((*child_n).min(n))
375        }
376        (CardinalityClass::BoundedAtMost(child_n), Some(n)) => {
377            CardinalityClass::BoundedAtMost((*child_n).min(n))
378        }
379        (_, Some(n)) => CardinalityClass::Bounded(n), // unbounded + Some(n) → Bounded(n)
380        // No truncation: inherit child's cardinality.
381        (c, None) => c.clone(),
382    };
383
384    let (index_addressable, natural_order, materialization) = match strategy {
385        StrategyName::Lex => (
386            child_meta.index_addressable, // inherit through Lex
387            NaturalOrder::Lex,
388            child_meta.materialization, // counter wrapper at most
389        ),
390        non_lex => {
391            // R2 (Phase 6) rewrites this into an indexed_order
392            // IR opcode; AST-level metadata stops here.
393            let working_set_size =
394                strategy_working_set(non_lex, &child_meta.index_addressable, truncation);
395            (
396                None,
397                NaturalOrder::Strategy(non_lex),
398                Materialization::BoundedBarrier { working_set_size },
399            )
400        }
401    };
402
403    Metadata {
404        cardinality,
405        index_addressable,
406        natural_order,
407        materialization,
408    }
409}
410
411// ---- cardinality combinators ----
412
413fn combine_cartesian_cardinality(children: &[Metadata]) -> CardinalityClass {
414    let mut has_continuous = false;
415    let mut has_discrete = false;
416    let mut has_unbounded = false;
417    let mut product: u64 = 1;
418    let mut overflow = false;
419    let mut discrete_axes: Vec<u64> = Vec::new();
420    let mut continuous_intervals: Vec<Interval> = Vec::new();
421    let mut continuous_measures: Vec<ProductMeasure> = Vec::new();
422
423    for m in children {
424        match &m.cardinality {
425            CardinalityClass::Bounded(n) => {
426                has_discrete = true;
427                discrete_axes.push(*n);
428                product = product.checked_mul(*n).unwrap_or_else(|| {
429                    overflow = true;
430                    u64::MAX
431                });
432            }
433            CardinalityClass::BoundedAtMost(n) => {
434                has_discrete = true;
435                discrete_axes.push(*n); // upper bound
436                product = product.checked_mul(*n).unwrap_or_else(|| {
437                    overflow = true;
438                    u64::MAX
439                });
440            }
441            CardinalityClass::Unbounded => {
442                has_unbounded = true;
443                has_discrete = true;
444                discrete_axes.push(0);
445            }
446            CardinalityClass::Continuous { intervals, measure }
447            | CardinalityClass::ContinuousAtMost {
448                intervals,
449                measure_at_most: measure,
450            } => {
451                has_continuous = true;
452                continuous_intervals.extend(intervals.iter().cloned());
453                continuous_measures.push(measure.clone());
454            }
455            CardinalityClass::Hybrid(h) => {
456                has_continuous = true;
457                has_discrete = true;
458                discrete_axes.extend(h.discrete_axes.iter().copied());
459                continuous_intervals.extend(h.continuous_axes.iter().cloned());
460                continuous_measures.push(h.measure.clone());
461            }
462        }
463    }
464
465    let _ = overflow; // discard; saturating product is the policy
466
467    if has_continuous && has_discrete {
468        CardinalityClass::Hybrid(Hybrid {
469            discrete_axes,
470            continuous_axes: continuous_intervals,
471            measure: simplify_measures(continuous_measures),
472        })
473    } else if has_continuous {
474        CardinalityClass::Continuous {
475            intervals: continuous_intervals,
476            measure: simplify_measures(continuous_measures),
477        }
478    } else if has_unbounded {
479        CardinalityClass::Unbounded
480    } else {
481        CardinalityClass::Bounded(product)
482    }
483}
484
485fn combine_cartesian_index_fn(children: &[Metadata]) -> Option<IndexFn> {
486    // All children must be addressable for the cartesian to be.
487    let all_addressable = children.iter().all(|m| m.index_addressable.is_some());
488    if !all_addressable {
489        return None;
490    }
491
492    let mut all_discrete = true;
493    let mut all_continuous = true;
494    let mut discrete_axes: Vec<u64> = Vec::new();
495    let mut continuous_intervals: Vec<Interval> = Vec::new();
496    let mut continuous_measures: Vec<ProductMeasure> = Vec::new();
497
498    for m in children {
499        match m.index_addressable.as_ref().unwrap() {
500            IndexFn::Lattice { axis_sizes } => {
501                all_continuous = false;
502                discrete_axes.extend(axis_sizes.iter().copied());
503            }
504            IndexFn::Continuous { intervals, measure } => {
505                all_discrete = false;
506                continuous_intervals.extend(intervals.iter().cloned());
507                continuous_measures.push(measure.clone());
508            }
509            IndexFn::Hybrid {
510                discrete_axes: d,
511                continuous_axes: c,
512                measure,
513            } => {
514                all_discrete = false;
515                all_continuous = false;
516                discrete_axes.extend(d.iter().copied());
517                continuous_intervals.extend(c.iter().cloned());
518                continuous_measures.push(measure.clone());
519            }
520            // Lockstep / Modular / Concatenation — these don't
521            // combine as cartesian axes (they're 1-D index
522            // spaces of their own); cartesian-of-zip / cartesian-
523            // of-union would need a richer addressing scheme.
524            // For now, fall back to None.
525            IndexFn::Lockstep { .. } | IndexFn::Modular { .. } | IndexFn::Concatenation { .. } => {
526                return None;
527            }
528        }
529    }
530
531    if all_discrete {
532        Some(IndexFn::Lattice {
533            axis_sizes: discrete_axes,
534        })
535    } else if all_continuous {
536        Some(IndexFn::Continuous {
537            intervals: continuous_intervals,
538            measure: simplify_measures(continuous_measures),
539        })
540    } else {
541        Some(IndexFn::Hybrid {
542            discrete_axes,
543            continuous_axes: continuous_intervals,
544            measure: simplify_measures(continuous_measures),
545        })
546    }
547}
548
549fn combine_zip_cardinality(children: &[Metadata], mode: ZipMode) -> CardinalityClass {
550    // V7 should have rejected mixed-class / continuous; here we
551    // assume discrete children.
552    let counts: Vec<Option<u64>> = children
553        .iter()
554        .map(|m| match &m.cardinality {
555            CardinalityClass::Bounded(n) | CardinalityClass::BoundedAtMost(n) => Some(*n),
556            CardinalityClass::Unbounded => None,
557            // Continuous / Hybrid here would be a V7 failure
558            // that slipped through; treat as Unbounded for
559            // metadata purposes.
560            _ => None,
561        })
562        .collect();
563
564    match mode {
565        ZipMode::Strict => {
566            // V7 should have caught mismatch. Use any bounded child's count.
567            counts
568                .iter()
569                .find_map(|c| *c)
570                .map(CardinalityClass::Bounded)
571                .unwrap_or(CardinalityClass::Unbounded)
572        }
573        ZipMode::Truncate => {
574            let bounded: Vec<u64> = counts.iter().filter_map(|c| *c).collect();
575            if bounded.is_empty() {
576                CardinalityClass::Unbounded
577            } else {
578                CardinalityClass::Bounded(*bounded.iter().min().unwrap())
579            }
580        }
581        ZipMode::Cycle => {
582            let bounded: Vec<u64> = counts.iter().filter_map(|c| *c).collect();
583            if counts.iter().any(Option::is_none) {
584                CardinalityClass::Unbounded
585            } else if let Some(max) = bounded.iter().max() {
586                CardinalityClass::Bounded(*max)
587            } else {
588                CardinalityClass::Bounded(0)
589            }
590        }
591    }
592}
593
594fn combine_zip_index_fn(children: &[Metadata], mode: ZipMode) -> Option<IndexFn> {
595    let all_addressable = children.iter().all(|m| m.index_addressable.is_some());
596    if !all_addressable {
597        return None;
598    }
599    let counts: Vec<u64> = children
600        .iter()
601        .filter_map(|m| match &m.cardinality {
602            CardinalityClass::Bounded(n) | CardinalityClass::BoundedAtMost(n) => Some(*n),
603            _ => None,
604        })
605        .collect();
606    if counts.len() != children.len() {
607        return None;
608    }
609    match mode {
610        ZipMode::Strict | ZipMode::Truncate => {
611            let length = match mode {
612                ZipMode::Strict => counts[0],
613                ZipMode::Truncate => *counts.iter().min().unwrap(),
614                ZipMode::Cycle => unreachable!(),
615            };
616            Some(IndexFn::Lockstep { length })
617        }
618        ZipMode::Cycle => Some(IndexFn::Modular { axis_sizes: counts }),
619    }
620}
621
622fn combine_union_cardinality(children: &[Metadata]) -> CardinalityClass {
623    let mut sum: u64 = 0;
624    let mut any_unbounded = false;
625    let mut any_atmost = false;
626    for m in children {
627        match &m.cardinality {
628            CardinalityClass::Bounded(n) => {
629                sum = sum.saturating_add(*n);
630            }
631            CardinalityClass::BoundedAtMost(n) => {
632                sum = sum.saturating_add(*n);
633                any_atmost = true;
634            }
635            CardinalityClass::Unbounded => {
636                any_unbounded = true;
637            }
638            // V9 should have caught continuous-in-union.
639            _ => any_unbounded = true,
640        }
641    }
642    if any_unbounded {
643        CardinalityClass::Unbounded
644    } else if any_atmost {
645        CardinalityClass::BoundedAtMost(sum)
646    } else {
647        CardinalityClass::Bounded(sum)
648    }
649}
650
651fn combine_union_index_fn(children: &[Metadata]) -> Option<IndexFn> {
652    let all_addressable = children.iter().all(|m| m.index_addressable.is_some());
653    if !all_addressable {
654        return None;
655    }
656    let segment_sizes: Vec<u64> = children
657        .iter()
658        .filter_map(|m| match &m.cardinality {
659            CardinalityClass::Bounded(n) | CardinalityClass::BoundedAtMost(n) => Some(*n),
660            _ => None,
661        })
662        .collect();
663    if segment_sizes.len() != children.len() {
664        return None;
665    }
666    Some(IndexFn::Concatenation { segment_sizes })
667}
668
669// ---- supporting helpers ----
670
671fn simplify_measures(measures: Vec<ProductMeasure>) -> ProductMeasure {
672    match measures.len() {
673        0 => ProductMeasure::Uniform,
674        1 => measures.into_iter().next().unwrap(),
675        _ => ProductMeasure::Product(measures),
676    }
677}
678
679/// Strategy-specific working-set size for use as
680/// `BoundedBarrier.working_set_size`. Pre-R2, the naïve form
681/// uses the input cardinality; with R2 push-down, the size
682/// shrinks to the strategy's closed-form minimum. The metadata
683/// here records the **R2-realized** size (the size the
684/// optimizer will achieve), so consumers reading metadata see
685/// the post-optimization budget.
686fn strategy_working_set(
687    strategy: StrategyName,
688    input: &Option<IndexFn>,
689    truncation: Option<u64>,
690) -> u64 {
691    match (strategy, input, truncation) {
692        // Halton / Sobol / Shuffle over an index-addressable
693        // input + truncation: O(n) draws.
694        (StrategyName::Halton, Some(_), Some(n))
695        | (StrategyName::Sobol, Some(_), Some(n))
696        | (StrategyName::Shuffle, Some(_), Some(n))
697        | (StrategyName::ReverseLex, Some(_), Some(n)) => n,
698        // Lhs: O(n * dim).
699        (StrategyName::Lhs, Some(idx), Some(n)) => {
700            let dim = lattice_dim(idx).max(1);
701            n.saturating_mul(dim as u64)
702        }
703        // Extrema (SRD-18d §214): `/k` selects the first k *strata*
704        // (interior count 0..k-1), not k tuples — the output is
705        // `≥ 2^dim` corners for k≥1 and grows to the full space. The
706        // materialize step buffers the whole input regardless, so the
707        // safe working-set bound is the input cardinality. (A tight
708        // first-k-strata sum would need per-axis interior sizes;
709        // deferred — over-reporting here is safe, under-reporting is
710        // not.)
711        (StrategyName::Extrema, Some(idx), Some(_k)) => index_fn_cardinality(idx),
712        // Shells / Diagonal / Antidiagonal: per-emitted O(N).
713        (StrategyName::Shells, Some(_), Some(n))
714        | (StrategyName::Diagonal, Some(_), Some(n))
715        | (StrategyName::Antidiagonal, Some(_), Some(n)) => n,
716        // No truncation: fall back to the input's cardinality.
717        (_, Some(idx), None) => index_fn_cardinality(idx),
718        // No addressable input: we can't compute a closed form;
719        // use the naïve "input cardinality" placeholder so the
720        // metadata still has a number (consumers should treat
721        // this as a conservative upper bound).
722        (_, None, Some(n)) => n,
723        (_, None, None) => 0,
724        // Lex with truncation over addressable input — counter
725        // wrapper, working set equals output size.
726        (StrategyName::Lex, Some(_), Some(n)) => n,
727    }
728}
729
730fn lattice_dim(idx: &IndexFn) -> usize {
731    match idx {
732        IndexFn::Lattice { axis_sizes } => axis_sizes.len(),
733        IndexFn::Continuous { intervals, .. } => intervals.len(),
734        IndexFn::Hybrid {
735            discrete_axes,
736            continuous_axes,
737            ..
738        } => discrete_axes.len() + continuous_axes.len(),
739        IndexFn::Lockstep { .. } | IndexFn::Modular { .. } => 1,
740        IndexFn::Concatenation { segment_sizes } => segment_sizes.len(),
741    }
742}
743
744fn index_fn_cardinality(idx: &IndexFn) -> u64 {
745    match idx {
746        IndexFn::Lattice { axis_sizes } => axis_sizes
747            .iter()
748            .copied()
749            .fold(1u64, |a, b| a.saturating_mul(b)),
750        IndexFn::Lockstep { length } => *length,
751        IndexFn::Modular { axis_sizes } => axis_sizes.iter().copied().max().unwrap_or(0),
752        IndexFn::Concatenation { segment_sizes } => segment_sizes
753            .iter()
754            .copied()
755            .fold(0u64, |a, b| a.saturating_add(b)),
756        // Continuous index has no integer cardinality.
757        IndexFn::Continuous { .. } | IndexFn::Hybrid { .. } => 0,
758    }
759}
760
761/// Walk children's source expressions for back-references to
762/// earlier-axis coordinate names. Used by cartesian metadata
763/// propagation to detect dependent sources per spec §3.2.
764fn detect_dependent_sources(children: &[Comprehension]) -> bool {
765    let mut prior_names: Vec<String> = Vec::new();
766    for child in children {
767        // First check if the child references any prior name in
768        // its source(s).
769        for name in collect_source_name_references(child) {
770            if prior_names.contains(&name) {
771                return true;
772            }
773        }
774        // Then add this child's coordinates to the prior set.
775        for n in child.coordinate_names() {
776            if !prior_names.contains(&n) {
777                prior_names.push(n);
778            }
779        }
780    }
781    false
782}
783
784/// Extract `{name}` interpolation references from source
785/// expressions in a comprehension subtree. Sources that carry
786/// raw strings (`Generator`, `WorkloadParamList`) are walked;
787/// `Literal`, `IntRange`, `ContinuousInterval`, `Distribution`
788/// contain no string references.
789fn collect_source_name_references(c: &Comprehension) -> Vec<String> {
790    let mut out = Vec::new();
791    walk_source_refs(c, &mut out);
792    out
793}
794
795fn walk_source_refs(c: &Comprehension, out: &mut Vec<String>) {
796    match c {
797        Comprehension::Clause { source, .. } => {
798            extract_source_refs(source, out);
799        }
800        Comprehension::Cartesian { children }
801        | Comprehension::Zip { children, .. }
802        | Comprehension::Union { children } => {
803            for c in children {
804                walk_source_refs(c, out);
805            }
806        }
807        Comprehension::Filter { child, .. } | Comprehension::Order { child, .. } => {
808            walk_source_refs(child, out);
809        }
810    }
811}
812
813fn extract_source_refs(source: &Source, out: &mut Vec<String>) {
814    let s = match source {
815        Source::Generator { expr, .. } => expr.as_str(),
816        Source::WorkloadParamList { name, .. } => name.as_str(),
817        _ => return,
818    };
819    let bytes = s.as_bytes();
820    let mut i = 0;
821    while i < bytes.len() {
822        if bytes[i] == b'{'
823            && let Some(close) = s[i + 1..].find('}')
824        {
825            let name = s[i + 1..i + 1 + close].trim();
826            if !name.is_empty()
827                && name.chars().all(|c| c.is_alphanumeric() || c == '_')
828                && !out.contains(&name.to_string())
829            {
830                out.push(name.to_string());
831            }
832            i += close + 2;
833            continue;
834        }
835        i += 1;
836    }
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842    use crate::comprehension::source::{LiteralValue, Source};
843
844    fn clause(name: &str, vs: &[i64]) -> Comprehension {
845        Comprehension::clause(
846            name,
847            Source::Literal {
848                values: vs.iter().map(|n| LiteralValue::Int(*n)).collect(),
849            },
850        )
851    }
852
853    fn continuous_clause(name: &str) -> Comprehension {
854        Comprehension::clause(
855            name,
856            Source::ContinuousInterval {
857                interval: Interval::closed(0.0, 1.0),
858                measure: ProductMeasure::Uniform,
859            },
860        )
861    }
862
863    #[test]
864    fn clause_metadata_for_bounded_source() {
865        let m = clause("k", &[1, 2, 3]).metadata();
866        assert_eq!(m.cardinality, CardinalityClass::Bounded(3));
867        assert_eq!(
868            m.index_addressable,
869            Some(IndexFn::Lattice {
870                axis_sizes: vec![3]
871            })
872        );
873        assert_eq!(m.natural_order, NaturalOrder::Lex);
874        assert_eq!(m.materialization, Materialization::Streaming);
875    }
876
877    #[test]
878    fn clause_metadata_for_continuous_source() {
879        let m = continuous_clause("alpha").metadata();
880        assert!(matches!(m.cardinality, CardinalityClass::Continuous { .. }));
881        assert!(matches!(
882            m.index_addressable,
883            Some(IndexFn::Continuous { .. })
884        ));
885        assert_eq!(m.natural_order, NaturalOrder::PendingSampling);
886        assert_eq!(m.materialization, Materialization::Streaming);
887    }
888
889    #[test]
890    fn cartesian_metadata_combines_lattice_axes() {
891        let c =
892            Comprehension::cartesian(vec![clause("k", &[1, 2]), clause("limit", &[10, 20, 30])]);
893        let m = c.metadata();
894        assert_eq!(m.cardinality, CardinalityClass::Bounded(6));
895        assert_eq!(
896            m.index_addressable,
897            Some(IndexFn::Lattice {
898                axis_sizes: vec![2, 3]
899            })
900        );
901        assert_eq!(m.natural_order, NaturalOrder::Lex);
902    }
903
904    #[test]
905    fn cartesian_metadata_for_hybrid() {
906        let c =
907            Comprehension::cartesian(vec![clause("k", &[1, 2, 3, 4]), continuous_clause("theta")]);
908        let m = c.metadata();
909        match m.cardinality {
910            CardinalityClass::Hybrid(h) => {
911                assert_eq!(h.discrete_axes, vec![4]);
912                assert_eq!(h.continuous_axes.len(), 1);
913            }
914            other => panic!("expected Hybrid, got {other:?}"),
915        }
916        assert!(matches!(m.index_addressable, Some(IndexFn::Hybrid { .. })));
917        assert_eq!(m.natural_order, NaturalOrder::PendingSampling);
918    }
919
920    #[test]
921    fn dependent_cartesian_produces_none_addressable() {
922        // clause replicas references {k} from the prior clause.
923        let dependent = Comprehension::cartesian(vec![
924            clause("k", &[1, 2, 3]),
925            Comprehension::clause(
926                "replicas",
927                Source::Generator {
928                    expr: "range(0, 2 * {k})".into(),
929                    cardinality_hint: Some(6),
930                },
931            ),
932        ]);
933        let m = dependent.metadata();
934        assert!(m.index_addressable.is_none());
935    }
936
937    #[test]
938    fn zip_strict_produces_lockstep_index_fn() {
939        let c = Comprehension::zip(
940            vec![clause("x", &[1, 2, 3]), clause("y", &[10, 20, 30])],
941            ZipMode::Strict,
942        );
943        let m = c.metadata();
944        assert_eq!(m.index_addressable, Some(IndexFn::Lockstep { length: 3 }));
945        assert_eq!(m.natural_order, NaturalOrder::Lockstep);
946        assert_eq!(m.materialization, Materialization::Streaming);
947    }
948
949    #[test]
950    fn zip_cycle_produces_modular_index_fn_and_barrier() {
951        let c = Comprehension::zip(
952            vec![clause("k", &[1, 2, 3, 4, 5]), clause("color", &[1, 2, 3])],
953            ZipMode::Cycle,
954        );
955        let m = c.metadata();
956        match m.index_addressable {
957            Some(IndexFn::Modular { axis_sizes }) => {
958                assert_eq!(axis_sizes, vec![5, 3]);
959            }
960            other => panic!("expected Modular, got {other:?}"),
961        }
962        // shorter child cardinality = 3 → barrier size 3
963        assert_eq!(
964            m.materialization,
965            Materialization::BoundedBarrier {
966                working_set_size: 3
967            }
968        );
969    }
970
971    #[test]
972    fn union_produces_concatenation_index_fn() {
973        let a = Comprehension::cartesian(vec![clause("k", &[1, 2]), clause("limit", &[10])]);
974        let b = Comprehension::cartesian(vec![clause("k", &[3, 4]), clause("limit", &[20])]);
975        let u = Comprehension::union(vec![a, b]);
976        let m = u.metadata();
977        assert_eq!(m.cardinality, CardinalityClass::Bounded(4));
978        assert_eq!(
979            m.index_addressable,
980            Some(IndexFn::Concatenation {
981                segment_sizes: vec![2, 2]
982            })
983        );
984        assert_eq!(m.natural_order, NaturalOrder::Sequential);
985    }
986
987    #[test]
988    fn filter_destroys_addressability() {
989        let inner =
990            Comprehension::cartesian(vec![clause("k", &[1, 2]), clause("limit", &[10, 20])]);
991        let filtered = Comprehension::filter(inner, "{k} > 0");
992        let m = filtered.metadata();
993        assert_eq!(m.cardinality, CardinalityClass::BoundedAtMost(4));
994        assert_eq!(m.index_addressable, None);
995    }
996
997    #[test]
998    fn lex_order_inherits_addressability() {
999        let inner =
1000            Comprehension::cartesian(vec![clause("k", &[1, 2]), clause("limit", &[10, 20])]);
1001        let ordered = Comprehension::order(inner, StrategyName::Lex, Some(2));
1002        let m = ordered.metadata();
1003        assert_eq!(m.cardinality, CardinalityClass::Bounded(2));
1004        assert!(matches!(m.index_addressable, Some(IndexFn::Lattice { .. })));
1005        assert_eq!(m.natural_order, NaturalOrder::Lex);
1006    }
1007
1008    #[test]
1009    fn non_lex_order_drops_ast_level_addressability() {
1010        let inner =
1011            Comprehension::cartesian(vec![clause("k", &[1, 2]), clause("limit", &[10, 20])]);
1012        let ordered = Comprehension::order(inner, StrategyName::Halton, Some(2));
1013        let m = ordered.metadata();
1014        assert!(m.index_addressable.is_none());
1015        match m.natural_order {
1016            NaturalOrder::Strategy(StrategyName::Halton) => {}
1017            other => panic!("expected Strategy(Halton), got {other:?}"),
1018        }
1019        assert_eq!(
1020            m.materialization,
1021            Materialization::BoundedBarrier {
1022                working_set_size: 2
1023            }
1024        );
1025    }
1026
1027    #[test]
1028    fn continuous_sampling_yields_bounded_cardinality() {
1029        let inner =
1030            Comprehension::cartesian(vec![continuous_clause("alpha"), continuous_clause("beta")]);
1031        let ordered = Comprehension::order(inner, StrategyName::Halton, Some(100));
1032        let m = ordered.metadata();
1033        assert_eq!(m.cardinality, CardinalityClass::Bounded(100));
1034        assert_eq!(
1035            m.materialization,
1036            Materialization::BoundedBarrier {
1037                working_set_size: 100
1038            }
1039        );
1040    }
1041
1042    #[test]
1043    fn metadata_propagation_is_idempotent() {
1044        let c = Comprehension::order(
1045            Comprehension::filter(
1046                Comprehension::cartesian(vec![clause("k", &[1, 2, 3]), clause("limit", &[10, 20])]),
1047                "{k} * {limit} > 5",
1048            ),
1049            StrategyName::Halton,
1050            Some(5),
1051        );
1052        let m1 = c.metadata();
1053        let m2 = c.metadata();
1054        assert_eq!(m1, m2);
1055    }
1056
1057    #[test]
1058    fn has_continuous_axis_classifier() {
1059        let lat = IndexFn::Lattice {
1060            axis_sizes: vec![3, 4],
1061        };
1062        assert!(!lat.has_continuous_axis());
1063
1064        let cont = IndexFn::Continuous {
1065            intervals: vec![Interval::closed(0.0, 1.0)],
1066            measure: ProductMeasure::Uniform,
1067        };
1068        assert!(cont.has_continuous_axis());
1069    }
1070
1071    #[test]
1072    fn multi_axis_lattice_classifier() {
1073        assert!(
1074            IndexFn::Lattice {
1075                axis_sizes: vec![3, 4]
1076            }
1077            .is_multi_axis_lattice()
1078        );
1079        assert!(
1080            !IndexFn::Lattice {
1081                axis_sizes: vec![3]
1082            }
1083            .is_multi_axis_lattice()
1084        );
1085        assert!(
1086            !IndexFn::Continuous {
1087                intervals: vec![Interval::closed(0.0, 1.0), Interval::closed(0.0, 1.0)],
1088                measure: ProductMeasure::Uniform,
1089            }
1090            .is_multi_axis_lattice()
1091        );
1092    }
1093}