Skip to main content

polydat_core/iteration/comprehension/
eval.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Comprehension spec evaluation — text → typed value list.
5//!
6//! ## What this module does
7//!
8//! A comprehension clause `var in expr` ships its `expr` as
9//! free-form workload-author text. At runtime, the executor
10//! needs to turn that text into a list of typed values to
11//! enumerate over. That's what [`evaluate_spec`] does, given a
12//! Polydat Kernel that holds the in-scope name space (own outputs +
13//! inherited externs from `materialize_wiring_from_outer`).
14//!
15//! ## Pipeline
16//!
17//! ```text
18//!   spec_text
19//!       │
20//!       ▼
21//!   interpolate_via_kernel  ← {name} → kernel.lookup(name)
22//!       │
23//!       ▼
24//!   eval_const_expr         ← optional: Polydat expression eval
25//!       │
26//!       ▼
27//!   parse_list_with_types   ← comma-split, per-element type
28//!       │
29//!       ▼
30//!   Vec<Value>              ← what the executor enumerates
31//! ```
32//!
33//! ## Where this used to live
34//!
35//! Pre-Phase-C this code lived in the host. The lift was driven by
36//! the principle that Polydat is the canonical owner of what a
37//! comprehension *means*, including how its spec strings resolve
38//! (see [comprehension_forms.md](../../../docs/design/comprehension_forms.md)).
39//! The host now consumes this
40//! API rather than implementing it.
41
42use std::collections::HashMap;
43use std::sync::Arc;
44
45use crate::ast::Value;
46use crate::kernel::PolydatKernel;
47use crate::kernel::interp::Lookup;
48use crate::kernel::interp::{interpolate_via_kernel, interpolate_with_lookup};
49
50/// Evaluate a comprehension clause's spec text against a kernel.
51///
52/// Steps:
53///  1. [`interpolate_via_kernel`] resolves `{name}` placeholders
54///     against the kernel's in-scope name space (own outputs +
55///     inherited extern values).
56///  2. Try `dsl::compile::eval_const_expr` on the result. On
57///     success with a `Str` value, re-parse as a comma-separated
58///     list with per-element type detection. Other typed
59///     variants become a single-element typed list.
60///  3. On eval failure (most common case for literal lists like
61///     `"1, 10"` which aren't valid Polydat const expressions), fall
62///     back to [`parse_list_with_types`] on the interpolated
63///     text — `1` → `U64`, `1.5` → `F64`, `true` → `Bool`,
64///     anything else → `Str`.
65///
66/// Errors propagate from interpolation (unresolved placeholder,
67/// runaway round count, etc.) — those are the user-facing
68/// actionable diagnostics.
69pub fn evaluate_spec(
70    spec_text: &str,
71    kernel: &dyn Lookup,
72) -> Result<Vec<Value>, crate::dsl::compile::EmbeddingError> {
73    evaluate_spec_internal(spec_text, kernel).map_err(|msg| {
74        if let Some(rest) = msg.strip_prefix("interpolation: unresolved placeholder '{")
75            && let Some(end) = rest.find('}')
76        {
77            let name = rest[..end].to_string();
78            return crate::dsl::compile::EmbeddingError::UnresolvedPlaceholder {
79                name,
80                source: spec_text.to_string(),
81            };
82        }
83        crate::dsl::compile::EmbeddingError::Parse {
84            source: spec_text.to_string(),
85            message: msg,
86            position: None,
87        }
88    })
89}
90
91fn evaluate_spec_internal(spec_text: &str, kernel: &dyn Lookup) -> Result<Vec<Value>, String> {
92    if let Some(values) = try_eval_all_cursor(spec_text, kernel)? {
93        return Ok(values);
94    }
95    // SRD-18f Stage 2 (non-breaking core): a *bare identifier*
96    // source is a direct wire/param/const reference. Resolve it
97    // against the kernel chain — the same `kernel.lookup` the
98    // `{name}` interpolation path uses — and peel/wrap its value
99    // via `iteration_interior`. This makes `mnc in mnc_values`
100    // work identically to `mnc in {mnc_values}`.
101    //
102    // If the bare name does NOT resolve, fall through to the
103    // legacy path, which treats it as a label string
104    // (`y in z` → ["z"]). The strict "unresolved bare ident is
105    // an error" enforcement (SRD-18f §6) is deferred to the
106    // breaking part of Stage 2 along with its test/workload
107    // migration.
108    if is_single_bare_ident(spec_text) {
109        // SRD-18f §6: a bare identifier source is a reference. It
110        // resolves against the kernel, or it's a hard error — it
111        // is NOT silently bound as its own name-string.
112        return match kernel.lookup(spec_text.trim()) {
113            Some(v) => Ok(
114                match crate::iteration::comprehension::source_values::iteration_interior(&v) {
115                    Some(interior) => interior,
116                    None => vec![v],
117                },
118            ),
119            None => Err(format!(
120                "comprehension source `{src}` did not resolve to a value — no \
121                 wire, const, param, or outer iter-var by that name is in scope \
122                 here. If you meant the literal string \"{src}\", quote it: \
123                 `\"{src}\"`.",
124                src = spec_text.trim(),
125            )),
126        };
127    }
128    let interpolated = crate::kernel::interp::interpolate_with_lookup(spec_text, |name| {
129        kernel.lookup(name).map(|v| v.to_display_string())
130    })?;
131    // SRD-18f Stage 2: list comprehension sugar `[e1, e2…, e3]`.
132    // Resolved after interpolation so `{name}` placeholders inside
133    // elements expand first; before the const-eval fallthrough so
134    // bracket structure isn't misparsed as an array-literal expr.
135    if let Some(values) = try_eval_bracket_list(&interpolated, kernel)? {
136        return Ok(values);
137    }
138    // SRD-18c Layer 2 / SRD-18e Push 3: range operator
139    // (`a..b`, `a..=b`, `a..b..s`, `a..=b..s`). Bounds and
140    // step are Polydat const expressions evaluated at this
141    // (post-interpolation) point.
142    if let Some(values) = try_eval_range(&interpolated)? {
143        return Ok(values);
144    }
145    // SRD-18c Layer 3 / SRD-18e Push 7: named generators.
146    if let Some(values) = try_eval_generator(&interpolated)? {
147        return Ok(values);
148    }
149    // SRD-18c Layer 5 / SRD-18e Push 9: set operators on lists.
150    if let Some(values) = try_eval_setop(&interpolated, kernel)? {
151        return Ok(values);
152    }
153    // SRD-18c §"Sequencer-style expansions" / Push 8: LUT
154    // facility (bucket / concat_seq / interval_seq).
155    if let Some(values) = try_eval_sequencer(&interpolated, kernel)? {
156        return Ok(values);
157    }
158    // SRD 71: kernel-aware partition sources — `subdivide(outer,
159    // n)` where `outer` is a partition iter-var bound by an
160    // enclosing `for:` clause.
161    if let Some(values) = try_eval_partition_call(&interpolated, kernel)? {
162        return Ok(values);
163    }
164    // SRD 71: `<param>.partitions` comprehension-position desugaring —
165    // resolve the param's spec string and expand it into its PartitionList.
166    if let Some(values) = try_eval_param_partitions(&interpolated, kernel)? {
167        return Ok(values);
168    }
169    match crate::dsl::compile::eval_const_expr(&interpolated) {
170        // SRD-18f relaxed source resolution: a resolved value is
171        // peeled one level if it has an iteration interior
172        // (native vector, JSON array, PartitionList per SRD-71,
173        // or a string → its comprehension tokens), else wrapped
174        // as a singleton. `iteration_interior` is the single
175        // canonical place that decision is made — this replaces
176        // the former per-type arms (Str→comma-split,
177        // PartitionList→unpack, other→wrap).
178        Ok(v) => Ok(
179            match crate::iteration::comprehension::source_values::iteration_interior(&v) {
180                Some(interior) => interior,
181                None => vec![v],
182            },
183        ),
184        // Fall back to the literal-list parse only when the text
185        // is unambiguously a comma-separated list of literals
186        // (e.g. `1, 10, 100` — `eval_const_expr` doesn't accept
187        // that shape because it isn't a single Polydat expression).
188        // Anything that looks like an expression (parens, GK
189        // operators, identifiers other than `true`/`false`) was
190        // *meant* to evaluate; if it failed, we MUST surface the
191        // failure rather than silently splitting and
192        // handing the workload an iter-var like
193        // `matching_profiles('x'` (truncated). The latter
194        // produces malformed downstream output six steps removed
195        // from the actual fault — a Push-2 kind of bad UX.
196        Err(eval_err) => {
197            // NOTE: SRD-18f §6 specifies that a single bare
198            // identifier that fails to evaluate is an *unresolved
199            // reference* and should be a hard error (with a
200            // quoting hint), not silently bound as its own
201            // name-string. That enforcement is **Stage 2** (the
202            // bare-word→reference change) because it flips every
203            // existing bare-label source (`y in z` meaning the
204            // string "z") and requires migrating those to quoted
205            // form. Until Stage 2, the legacy literal-list
206            // fallback below preserves bare-label-as-string.
207            if looks_like_literal_list(&interpolated) {
208                // A bare unquoted token list strips on the same
209                // separator rule as a string comprehension.
210                Ok(
211                    crate::iteration::comprehension::source_values::strip_string_tokens(
212                        &interpolated,
213                    ),
214                )
215            } else {
216                Err(format!(
217                    "for_each clause expression failed to evaluate: {eval_err}\n\
218                     spec: {interpolated}\n\
219                     If this was meant as a literal list (e.g. `1, 10, 100`), \
220                     it should contain only literal values separated by commas. \
221                     If it was meant as an expression, fix the underlying \
222                     evaluation error."
223                ))
224            }
225        }
226    }
227}
228
229/// Heuristic: does this interpolated spec text look like a
230/// "literal list" (comma-separated literals like `1, 10, 100` or
231/// `foo, bar, baz`) rather than an expression?
232///
233/// True only when no character suggests an expression: no
234/// parentheses, no operators, no string-quote characters that
235/// would imply a function-call shape. Whitespace, digits,
236/// alphanumerics, dots (for floats), minus (for negatives), and
237/// commas (the separator) are all OK.
238///
239/// The point of this gate is to keep "list" specs (`for: "k in 1,
240/// 10, 100"`) working through the literal-list fallback while
241/// still surfacing real evaluation failures for expression specs
242/// like `matching_profiles('x', 'y')`. A wrong call on a
243/// borderline case here is cheap — it just produces a clearer
244/// error from the eval layer instead of swallowed garbage.
245/// SRD-18f Stage 2 — list comprehension sugar. Evaluate a
246/// bracketed source `[e1, e2…, e3]` to its bound sequence,
247/// peeling exactly one level:
248///   - a plain element contributes its value, whole (no peel);
249///   - a spread element `S…` / `S...` contributes `S`'s
250///     iteration interior (peel one level); a non-iterable `S`
251///     under spread is a hard error.
252///
253/// Elements are parsed by the core expression grammar: a bare
254/// identifier is a wire/param reference (resolved against the
255/// kernel), a quoted token is a string, numbers/bools are
256/// literals. Returns `Ok(None)` when `text` is not a bracketed
257/// list (so the caller falls through to the other source forms).
258fn try_eval_bracket_list(text: &str, kernel: &dyn Lookup) -> Result<Option<Vec<Value>>, String> {
259    let t = text.trim();
260    if !(t.starts_with('[') && t.ends_with(']') && t.len() >= 2) {
261        return Ok(None);
262    }
263    let inner = &t[1..t.len() - 1];
264    if inner.trim().is_empty() {
265        return Ok(Some(Vec::new()));
266    }
267    let mut out = Vec::new();
268    for elem in split_args_top_level(inner) {
269        let elem = elem.trim();
270        // Spread suffix: `…` (U+2026) or `...`.
271        let (expr, spread) = if let Some(stripped) = elem.strip_suffix('…') {
272            (stripped.trim(), true)
273        } else if let Some(stripped) = elem.strip_suffix("...") {
274            (stripped.trim(), true)
275        } else {
276            (elem, false)
277        };
278        if expr.is_empty() {
279            return Err("empty element in list comprehension `[...]`".to_string());
280        }
281        let value = eval_element_value(expr, kernel)?;
282        if spread {
283            match crate::iteration::comprehension::source_values::iteration_interior(&value) {
284                Some(interior) => out.extend(interior),
285                None => {
286                    return Err(format!(
287                        "list comprehension spread `{expr}…` requires an iterable \
288                     source, but `{expr}` resolved to a scalar \
289                     {ty:?}. Use `[{expr}]` to pass it as a single element, \
290                     or supply a list.",
291                        ty = value.port_type(),
292                    ));
293                }
294            }
295        } else {
296            out.push(value);
297        }
298    }
299    Ok(Some(out))
300}
301
302/// Resolve one list-comprehension element to a single value (no
303/// peeling). A bare identifier is a wire/param/const reference
304/// resolved against the kernel; anything else (quoted string,
305/// number, bool, expression) goes through the const evaluator.
306/// SRD-18f §6: an unresolved bare reference is a hard error with
307/// a quoting hint, not a silent literal-name binding.
308fn eval_element_value(expr: &str, kernel: &dyn Lookup) -> Result<Value, String> {
309    let e = expr.trim();
310    if is_single_bare_ident(e) {
311        return kernel.lookup(e).ok_or_else(|| {
312            format!(
313                "list element `{e}` did not resolve to a value — no wire, const, \
314             param, or outer iter-var by that name is in scope here. \
315             If you meant the literal string \"{e}\", quote it: `\"{e}\"`."
316            )
317        });
318    }
319    crate::dsl::compile::eval_const_expr(e)
320        .map_err(|err| format!("list element `{e}` failed to evaluate: {err}"))
321}
322
323/// True when `text` is exactly one bare identifier
324/// (`[A-Za-z_][A-Za-z0-9_]*`), excluding `true`/`false`. A bare
325/// identifier source is a direct reference resolved against the
326/// kernel (SRD-18f Stage 2); the keyword literals are values.
327fn is_single_bare_ident(text: &str) -> bool {
328    let t = text.trim();
329    if t == "true" || t == "false" {
330        return false;
331    }
332    let mut chars = t.chars();
333    match chars.next() {
334        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
335        _ => return false,
336    }
337    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
338}
339
340fn looks_like_literal_list(text: &str) -> bool {
341    let trimmed = text.trim();
342    if trimmed.is_empty() {
343        return false;
344    }
345    !trimmed.chars().any(|c| {
346        matches!(
347            c,
348            '(' | ')'
349                | '['
350                | ']'
351                | '{'
352                | '}'
353                | '\''
354                | '"'
355                | '+'
356                | '*'
357                | '/'
358                | '%'
359                | '='
360                | '<'
361                | '>'
362                | '!'
363                | '&'
364                | '|'
365                | '~'
366                | '^'
367                | '?'
368        )
369    })
370}
371
372/// Pre-evaluate a clause's spec text at synthesis time, using
373/// `probes` for prior clauses' first values and `workload_params`
374/// as a fallback source for names not yet promoted to workload-
375/// kernel `const` bindings.
376///
377/// The runtime dispatcher uses [`evaluate_spec`] directly because
378/// (a) the runtime kernel has prior-clause values as real input
379/// slots, not text probes, and (b) by then workload params are
380/// already injected as final bindings on the for_each scope's
381/// kernel via the synthesis path.
382pub fn pre_evaluate_clause(
383    spec_text: &str,
384    parent_kernel: &dyn Lookup,
385    workload_params: &HashMap<String, String>,
386    probes: &HashMap<String, String>,
387) -> Result<Vec<Value>, String> {
388    // The `all(<cursor>)` form resolves cursor extents from the
389    // parent kernel's auxiliary outputs; it doesn't fit the
390    // const-eval pipeline (which returns a single Value), so it's
391    // intercepted here too — same as `evaluate_spec`.
392    if let Some(values) = try_eval_all_cursor(spec_text, parent_kernel)? {
393        return Ok(values);
394    }
395    // SRD-18f Stage 2: a bare identifier source is a direct
396    // reference. Resolve it at synthesis the same way the runtime
397    // `evaluate_spec` does — against a prior iter-var probe, then
398    // the parent kernel, then the workload params — so a dependent
399    // clause (e.g. `limit in {k_{k}_limits}`) sees the *typed*
400    // prior value and infers the right extern type. Without this,
401    // `k in k_values` left `k`'s probe as the literal name and the
402    // dependent `limit` defaulted to String (the query_sweep bug
403    // the scenario-synthesis coverage gap was hiding).
404    if is_single_bare_ident(spec_text) {
405        let name = spec_text.trim();
406        if let Some(pv) = probes.get(name) {
407            return Ok(crate::iteration::comprehension::source_values::strip_string_tokens(pv));
408        }
409        if let Some(v) = parent_kernel.lookup(name) {
410            return Ok(
411                match crate::iteration::comprehension::source_values::iteration_interior(&v) {
412                    Some(interior) => interior,
413                    None => vec![v],
414                },
415            );
416        }
417        if let Some(s) = workload_params.get(name) {
418            return Ok(crate::iteration::comprehension::source_values::strip_string_tokens(s));
419        }
420        return Err(format!(
421            "comprehension source `{name}` did not resolve to a value — no wire, \
422             const, param, or outer iter-var by that name is in scope here. \
423             If you meant the literal string \"{name}\", quote it: `\"{name}\"`."
424        ));
425    }
426    let mut text = spec_text.to_string();
427    for (var, probe_value) in probes {
428        text = text.replace(&format!("{{{var}}}"), probe_value);
429    }
430
431    let interpolated = interpolate_with_lookup(&text, |name| {
432        parent_kernel
433            .lookup(name)
434            .map(|v| v.to_display_string())
435            .or_else(|| workload_params.get(name).cloned())
436    })?;
437
438    // Push 3: range operator on the pre-evaluation path too.
439    if let Some(values) = try_eval_range(&interpolated)? {
440        return Ok(values);
441    }
442    // Push 7 / 9 / 8 — same generator / set-op / sequencer
443    // shortcuts the runtime path uses.
444    if let Some(values) = try_eval_generator(&interpolated)? {
445        return Ok(values);
446    }
447    if let Some(values) = try_eval_setop(&interpolated, parent_kernel)? {
448        return Ok(values);
449    }
450    if let Some(values) = try_eval_sequencer(&interpolated, parent_kernel)? {
451        return Ok(values);
452    }
453    // SRD 71: kernel-aware partition sources, same as the
454    // runtime path. At pre-evaluation the outer iter-var may
455    // not be installed yet; `try_eval_partition_call` returns a
456    // single placeholder partition in that case so iter-var
457    // type detection still lands on `ext`.
458    if let Some(values) = try_eval_partition_call(&interpolated, parent_kernel)? {
459        return Ok(values);
460    }
461    // SRD 71: `<param>.partitions` comprehension-position desugaring (same
462    // rule as the runtime path; the param may already be installed here).
463    if let Some(values) = try_eval_param_partitions(&interpolated, parent_kernel)? {
464        return Ok(values);
465    }
466    let value_str = match crate::dsl::compile::eval_const_expr(&interpolated) {
467        Ok(Value::Str(s)) => s.to_string(),
468        // SRD 71: `<param>.partitions` and `partitions(spec, ...)`
469        // both evaluate to a `PartitionList` Ext value. Unpack
470        // its entries into a vec of individual `Partition`
471        // values so the for-clause iterates partition-by-
472        // partition.
473        Ok(ref v) if v.as_partition_list().is_some() => {
474            let list = v.as_partition_list().unwrap();
475            return Ok(list
476                .as_slice()
477                .iter()
478                .map(|p| Value::from_partition(*p))
479                .collect());
480        }
481        Ok(other) => return Ok(vec![other]),
482        // Mirrors `evaluate_spec`'s gating: only fall back to
483        // parse_list_with_types when the text is unambiguously a
484        // literal list. See `looks_like_literal_list` for the
485        // rationale.
486        Err(eval_err) => {
487            if looks_like_literal_list(&interpolated) {
488                interpolated
489            } else {
490                return Err(format!(
491                    "for_each clause expression failed to evaluate: {eval_err}\n\
492                     spec: {interpolated}\n\
493                     If this was meant as a literal list (e.g. `1, 10, 100`), \
494                     it should contain only literal values separated by commas. \
495                     If it was meant as an expression, fix the underlying \
496                     evaluation error."
497                ));
498            }
499        }
500    };
501    Ok(parse_list_with_types(&value_str))
502}
503
504/// Parse a comma-separated text list, detecting each element's
505/// native type. SRD-18b's "native types as the general rule":
506/// `"1, 10"` → `[U64(1), U64(10)]`, `"1.5, 2.5"` → `[F64(...)]`,
507/// mixed → each element gets its own native type.
508pub fn parse_list_with_types(text: &str) -> Vec<Value> {
509    text.split(',')
510        .map(str::trim)
511        .filter(|s| !s.is_empty())
512        .map(|s| {
513            if let Ok(n) = s.parse::<u64>() {
514                Value::U64(n)
515            } else if let Ok(n) = s.parse::<f64>() {
516                Value::F64(n)
517            } else if s == "true" {
518                Value::Bool(true)
519            } else if s == "false" {
520                Value::Bool(false)
521            } else {
522                Value::Str(s.to_string().into())
523            }
524        })
525        .collect()
526}
527
528/// Recognize the comprehension-level `all(<cursor>)` clause form
529/// and resolve it against the parent kernel's cursor extent
530/// auxiliary outputs.
531///
532/// Cursors declared via the Polydat `cursor name = Cursor(start, end)`
533/// shape compile to two well-known auxiliary outputs on the
534/// kernel: `__cursor_extent_<name>_start` and
535/// `__cursor_extent_<name>_end`. Reading those gives the cursor's
536/// resolved extent at scope-init time. `all(<cursor>)` lowers to
537/// the half-open ordinal range `[start, end)` as a `Vec<Value::U64>`.
538///
539/// Returns:
540/// - `Ok(Some(values))` if `spec_text` matches the `all(<ident>)`
541///   shape and the cursor's extent resolved successfully.
542/// - `Ok(None)` if `spec_text` doesn't match — caller continues
543///   with the normal interpolation + const-eval pipeline.
544/// - `Err(...)` if the form matched but the cursor's extent
545///   couldn't be resolved (cursor not in scope, extent wires
546///   missing, etc.) — surfaced as a clause-level diagnostic.
547fn try_eval_all_cursor(spec_text: &str, kernel: &dyn Lookup) -> Result<Option<Vec<Value>>, String> {
548    let trimmed = spec_text.trim();
549    let Some(stripped) = trimmed.strip_prefix("all(") else {
550        return Ok(None);
551    };
552    let Some(arg) = stripped.strip_suffix(')') else {
553        return Ok(None);
554    };
555    let cursor_name = arg.trim();
556    if cursor_name.is_empty() || !is_valid_ident(cursor_name) {
557        return Ok(None);
558    }
559
560    let start_key = format!("__cursor_extent_{cursor_name}_start");
561    let end_key = format!("__cursor_extent_{cursor_name}_end");
562    let start = kernel
563        .lookup(&start_key)
564        .and_then(|v| match v {
565            Value::U64(n) => Some(n),
566            _ => None,
567        })
568        .ok_or_else(|| {
569            format!(
570                "all({cursor_name}): cursor '{cursor_name}' has no resolvable extent — \
571             check that the cursor is declared at or above this scope and that \
572             its range arguments are init-resolvable. Looked for output '{start_key}'."
573            )
574        })?;
575    let end = kernel
576        .lookup(&end_key)
577        .and_then(|v| match v {
578            Value::U64(n) => Some(n),
579            _ => None,
580        })
581        .ok_or_else(|| {
582            format!(
583                "all({cursor_name}): missing auxiliary output '{end_key}' on the parent kernel."
584            )
585        })?;
586
587    if end < start {
588        return Err(format!(
589            "all({cursor_name}): cursor extent end={end} is less than start={start} — \
590             cannot enumerate a negative-extent range."
591        ));
592    }
593    Ok(Some((start..end).map(Value::U64).collect()))
594}
595
596fn is_valid_ident(s: &str) -> bool {
597    let mut chars = s.chars();
598    match chars.next() {
599        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
600        _ => return false,
601    }
602    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
603}
604
605/// SRD-18c Layer 2 / SRD-18e Push 3: recognise the range
606/// operator and expand it into a `Vec<Value>`.
607///
608/// Four shapes:
609/// - `a..b`         half-open with step 1
610/// - `a..=b`        closed with step 1
611/// - `a..b..s`      half-open with step `s`
612/// - `a..=b..s`     closed with step `s`
613///
614/// Bounds and step are Polydat const expressions; this function
615/// evaluates each segment via `eval_const_expr`. Numeric
616/// type follows the bounds: if both are integers, the
617/// emitted list is `Value::U64`; otherwise `Value::F64`.
618///
619/// Returns:
620/// - `Ok(Some(values))` on a successful range expansion.
621/// - `Ok(None)` when `text` doesn't have a top-paren-depth
622///   `..` at all — caller falls through to the standard
623///   const-eval / list-parse path.
624/// - `Err(...)` when the form matches but evaluation fails
625///   (bound non-numeric, step is zero, bounds diverge from
626///   step direction, etc.).
627fn try_eval_range(text: &str) -> Result<Option<Vec<Value>>, String> {
628    let trimmed = text.trim();
629    let chars: Vec<char> = trimmed.chars().collect();
630
631    // Find every top-paren-depth `..` (with optional `=`).
632    // Returns positions of the `..` start and whether the
633    // following `=` was present.
634    let mut splits: Vec<(usize, bool)> = Vec::new();
635    let mut depth: i32 = 0;
636    let mut i = 0;
637    while i < chars.len() {
638        let c = chars[i];
639        match c {
640            '(' | '[' | '{' => depth += 1,
641            ')' | ']' | '}' => depth -= 1,
642            '"' | '\'' => {
643                // Skip the rest of the quoted run.
644                let q = c;
645                i += 1;
646                while i < chars.len() && chars[i] != q {
647                    i += 1;
648                }
649            }
650            '.' if depth == 0 && i + 1 < chars.len() && chars[i + 1] == '.' => {
651                let inclusive = i + 2 < chars.len() && chars[i + 2] == '=';
652                splits.push((i, inclusive));
653                i += if inclusive { 3 } else { 2 };
654                continue;
655            }
656            _ => {}
657        }
658        i += 1;
659    }
660
661    if splits.is_empty() {
662        return Ok(None);
663    }
664    if splits.len() > 2 {
665        return Err(format!(
666            "range expression '{trimmed}': more than two `..` operators \
667             at top level — expected one of `a..b`, `a..=b`, `a..b..s`, \
668             or `a..=b..s`"
669        ));
670    }
671    if splits.len() == 2 && splits[1].1 {
672        return Err(format!(
673            "range expression '{trimmed}': step delimiter cannot be \
674             `..=` — only the bound separator may be inclusive"
675        ));
676    }
677
678    // Slice out the segments.
679    let inclusive = splits[0].1;
680    let first_end = splits[0].0;
681    let after_first = first_end + if inclusive { 3 } else { 2 };
682    let (start_text, mid_text, step_text) = match splits.len() {
683        1 => {
684            let start_s: String = chars[..first_end].iter().collect();
685            let end_s: String = chars[after_first..].iter().collect();
686            (start_s, end_s, None)
687        }
688        2 => {
689            let mid_end = splits[1].0;
690            let after_mid = mid_end + 2; // `..` only, not `..=`
691            let start_s: String = chars[..first_end].iter().collect();
692            let mid_s: String = chars[after_first..mid_end].iter().collect();
693            let step_s: String = chars[after_mid..].iter().collect();
694            (start_s, mid_s, Some(step_s))
695        }
696        _ => unreachable!(),
697    };
698
699    let start_val = eval_range_segment(&start_text, "range start")?;
700    let end_val = eval_range_segment(&mid_text, "range end")?;
701    let step_val = match step_text {
702        Some(s) => Some(eval_range_segment(&s, "range step")?),
703        None => None,
704    };
705
706    Ok(Some(expand_range(
707        start_val, end_val, step_val, inclusive, trimmed,
708    )?))
709}
710
711fn eval_range_segment(text: &str, what: &str) -> Result<Value, String> {
712    let trimmed = text.trim();
713    if trimmed.is_empty() {
714        return Err(format!("range expression: {what} is empty"));
715    }
716    crate::dsl::compile::eval_const_expr(trimmed)
717        .map_err(|e| format!("range expression: {what} '{trimmed}' did not const-fold — {e}"))
718}
719
720/// Materialise the value list once start/end/step have been
721/// const-folded. If any of the three is `F64`, the whole list
722/// is `F64`; otherwise everything is `U64`.
723fn expand_range(
724    start: Value,
725    end: Value,
726    step: Option<Value>,
727    inclusive: bool,
728    src: &str,
729) -> Result<Vec<Value>, String> {
730    let any_float = matches!(start, Value::F64(_))
731        || matches!(end, Value::F64(_))
732        || matches!(step, Some(Value::F64(_)));
733
734    let to_f64 = |v: &Value| -> Result<f64, String> {
735        match v {
736            Value::U64(n) => Ok(*n as f64),
737            Value::F64(f) => Ok(*f),
738            other => Err(format!(
739                "range expression '{src}': bound has non-numeric value {other:?}"
740            )),
741        }
742    };
743    let to_i64 = |v: &Value| -> Result<i64, String> {
744        match v {
745            Value::U64(n) => i64::try_from(*n).map_err(|_| {
746                format!("range expression '{src}': bound {n} exceeds signed 64-bit range")
747            }),
748            Value::F64(f) => {
749                if f.fract() == 0.0 && *f >= i64::MIN as f64 && *f <= i64::MAX as f64 {
750                    Ok(*f as i64)
751                } else {
752                    Err(format!(
753                        "range expression '{src}': float bound {f} is not integral; \
754                         mix with an explicit float step (e.g. `1.0..10..0.5`) for a float range"
755                    ))
756                }
757            }
758            other => Err(format!(
759                "range expression '{src}': bound has non-numeric value {other:?}"
760            )),
761        }
762    };
763
764    if any_float {
765        let s = to_f64(&start)?;
766        let e = to_f64(&end)?;
767        let st = match step.as_ref() {
768            Some(v) => to_f64(v)?,
769            None => 1.0,
770        };
771        if st == 0.0 {
772            return Err(format!("range expression '{src}': step is zero"));
773        }
774        // Direction must match (start < end ⇒ step > 0; start > end ⇒ step < 0).
775        if (e - s).is_sign_positive() && st < 0.0 {
776            return Ok(Vec::new());
777        }
778        if (e - s).is_sign_negative() && st > 0.0 {
779            return Ok(Vec::new());
780        }
781        let mut out = Vec::new();
782        let mut cur = s;
783        let cmp = |x: f64| -> bool {
784            if st > 0.0 {
785                if inclusive {
786                    x <= e + 1e-12
787                } else {
788                    x < e - 1e-12
789                }
790            } else if inclusive {
791                x >= e - 1e-12
792            } else {
793                x > e + 1e-12
794            }
795        };
796        while cmp(cur) {
797            out.push(Value::F64(cur));
798            cur += st;
799        }
800        return Ok(out);
801    }
802
803    // Integer range.
804    let s = to_i64(&start)?;
805    let e = to_i64(&end)?;
806    let st = match step.as_ref() {
807        Some(v) => to_i64(v)?,
808        None => 1,
809    };
810    if st == 0 {
811        return Err(format!("range expression '{src}': step is zero"));
812    }
813    if st > 0 && s > e {
814        return Ok(Vec::new());
815    }
816    if st < 0 && s < e {
817        return Ok(Vec::new());
818    }
819    let mut out = Vec::new();
820    let mut cur = s;
821    let cmp = |x: i64| -> bool {
822        if st > 0 {
823            if inclusive { x <= e } else { x < e }
824        } else if inclusive {
825            x >= e
826        } else {
827            x > e
828        }
829    };
830    while cmp(cur) {
831        if cur < 0 {
832            return Err(format!(
833                "range expression '{src}': negative value {cur} can't be \
834                 represented as Value::U64; use a float range \
835                 (mix any bound or step with `.0`) for signed walks"
836            ));
837        }
838        out.push(Value::U64(cur as u64));
839        cur = cur.saturating_add(st);
840        if (st > 0 && cur < s) || (st < 0 && cur > s) {
841            // saturated; would loop forever on overflow.
842            break;
843        }
844    }
845    Ok(out)
846}
847
848// ============================================================
849// Function-call dispatch (Pushes 7, 8, 9)
850// ============================================================
851
852/// Recognise `name(args)` at the top paren depth. Returns
853/// `Some((name, args))` when the entire `text` is exactly
854/// one function call (with balanced parens, possibly empty
855/// args). Quoted strings within args are walked as opaque
856/// runs so internal commas / parens don't trip the split.
857fn parse_func_call(text: &str) -> Option<(&str, &str)> {
858    let trimmed = text.trim();
859    if !trimmed.ends_with(')') {
860        return None;
861    }
862    let open = trimmed.find('(')?;
863    let name = trimmed[..open].trim();
864    if name.is_empty() || !is_valid_ident(name) {
865        return None;
866    }
867    // Make sure the closing `)` matches the opening — i.e.
868    // the entire text is a single call, not `f(a) + g(b)`.
869    let chars: Vec<char> = trimmed.chars().collect();
870    let mut depth = 0i32;
871    let mut in_quote: Option<char> = None;
872    for (i, &c) in chars.iter().enumerate().skip(open) {
873        match (c, in_quote) {
874            ('"' | '\'', None) => in_quote = Some(c),
875            (q, Some(open_q)) if q == open_q => in_quote = None,
876            ('(', None) => depth += 1,
877            (')', None) => {
878                depth -= 1;
879                if depth == 0 {
880                    if i != chars.len() - 1 {
881                        return None; // close mid-text
882                    }
883                    let args: String = chars[open + 1..i].iter().collect();
884                    // SAFETY: trimmed lives for fn duration; we
885                    // index into the original string via slices
886                    // with care. Instead of returning a borrowed
887                    // slice from the local `args` String, return
888                    // the slices directly from `trimmed`.
889                    let _ = args;
890                    let name_slice = &trimmed[..open];
891                    let args_slice = &trimmed[open + 1..trimmed.len() - 1];
892                    return Some((name_slice.trim(), args_slice));
893                }
894            }
895            _ => {}
896        }
897    }
898    None
899}
900
901/// Split a function-argument list on top-level commas. Skips
902/// commas inside parens, brackets, braces, or quoted strings.
903fn split_args_top_level(args: &str) -> Vec<&str> {
904    let mut out: Vec<&str> = Vec::new();
905    let chars: Vec<char> = args.chars().collect();
906    let bytes_per_char: Vec<usize> = chars.iter().map(|c| c.len_utf8()).collect();
907    let mut start_byte = 0usize;
908    let mut byte = 0usize;
909    let mut depth = 0i32;
910    let mut in_quote: Option<char> = None;
911    for (i, &c) in chars.iter().enumerate() {
912        match (c, in_quote) {
913            ('"' | '\'', None) => in_quote = Some(c),
914            (q, Some(open_q)) if q == open_q => in_quote = None,
915            ('(' | '[' | '{', None) => depth += 1,
916            (')' | ']' | '}', None) => depth -= 1,
917            (',', None) if depth == 0 => {
918                let seg = &args[start_byte..byte];
919                out.push(seg.trim());
920                start_byte = byte + bytes_per_char[i];
921            }
922            _ => {}
923        }
924        byte += bytes_per_char[i];
925    }
926    let last = &args[start_byte..];
927    if !last.trim().is_empty() || !out.is_empty() {
928        out.push(last.trim());
929    }
930    out
931}
932
933/// Parse a single argument text as a `u64`. Errors carry the
934/// expected-form context for the user.
935fn parse_u64_arg(text: &str, what: &str) -> Result<u64, String> {
936    let trimmed = text.trim();
937    trimmed
938        .parse::<u64>()
939        .map_err(|_| format!("{what}: expected non-negative integer, got '{trimmed}'"))
940}
941
942/// Parse a single argument as either u64 or f64. Returns the
943/// f64 representation regardless (callers that need an int
944/// check `.fract() == 0.0`).
945fn parse_num_arg(text: &str, what: &str) -> Result<f64, String> {
946    let trimmed = text.trim();
947    trimmed
948        .parse::<f64>()
949        .map_err(|_| format!("{what}: expected numeric, got '{trimmed}'"))
950}
951
952// ============================================================
953// SRD-18c Layer 3 / SRD-18e Push 7: named generators
954// ============================================================
955
956/// Recognise `fib(n)`, `pow2(n)`, `geometric(...)`, etc. and
957/// expand to a `Vec<Value>`. Returns `Ok(None)` when the
958/// text isn't a known generator call (caller falls through
959/// to set-op / sequencer / const-eval paths).
960fn try_eval_generator(text: &str) -> Result<Option<Vec<Value>>, String> {
961    let Some((name, args)) = parse_func_call(text) else {
962        return Ok(None);
963    };
964    let arg_list = split_args_top_level(args);
965    match name {
966        "fib" => {
967            if arg_list.len() != 1 {
968                return Err(format!(
969                    "fib(n): expected 1 argument, got {}",
970                    arg_list.len()
971                ));
972            }
973            let n = parse_u64_arg(arg_list[0], "fib(n)")?;
974            Ok(Some(generate_fib_n(n)))
975        }
976        "fib_until" => {
977            if arg_list.len() != 1 {
978                return Err(format!(
979                    "fib_until(max): expected 1 argument, got {}",
980                    arg_list.len()
981                ));
982            }
983            let max = parse_u64_arg(arg_list[0], "fib_until(max)")?;
984            Ok(Some(generate_fib_until(max)))
985        }
986        "pow2" => {
987            if arg_list.len() != 1 {
988                return Err(format!(
989                    "pow2(n): expected 1 argument, got {}",
990                    arg_list.len()
991                ));
992            }
993            let n = parse_u64_arg(arg_list[0], "pow2(n)")?;
994            Ok(Some(generate_pow2_n(n)))
995        }
996        "pow2_until" => {
997            if arg_list.len() != 1 {
998                return Err(format!(
999                    "pow2_until(max): expected 1 argument, got {}",
1000                    arg_list.len()
1001                ));
1002            }
1003            let max = parse_u64_arg(arg_list[0], "pow2_until(max)")?;
1004            Ok(Some(generate_pow2_until(max)))
1005        }
1006        "binomial" => {
1007            if arg_list.len() != 1 {
1008                return Err(format!(
1009                    "binomial(n): expected 1 argument, got {}",
1010                    arg_list.len()
1011                ));
1012            }
1013            let n = parse_u64_arg(arg_list[0], "binomial(n)")?;
1014            Ok(Some(generate_binomial(n)))
1015        }
1016        "geometric" => {
1017            if arg_list.len() != 3 {
1018                return Err(format!(
1019                    "geometric(start, factor, n): expected 3 args, got {}",
1020                    arg_list.len()
1021                ));
1022            }
1023            let start = parse_num_arg(arg_list[0], "geometric.start")?;
1024            let factor = parse_num_arg(arg_list[1], "geometric.factor")?;
1025            let n = parse_u64_arg(arg_list[2], "geometric.n")?;
1026            Ok(Some(generate_geometric(start, factor, n)))
1027        }
1028        "geometric_until" => {
1029            if arg_list.len() != 3 {
1030                return Err(format!(
1031                    "geometric_until(start, factor, max): expected 3 args, got {}",
1032                    arg_list.len()
1033                ));
1034            }
1035            let start = parse_num_arg(arg_list[0], "geometric_until.start")?;
1036            let factor = parse_num_arg(arg_list[1], "geometric_until.factor")?;
1037            let max = parse_num_arg(arg_list[2], "geometric_until.max")?;
1038            Ok(Some(generate_geometric_until(start, factor, max)))
1039        }
1040        "linear_starts" => {
1041            if arg_list.len() != 3 {
1042                return Err(format!(
1043                    "linear_starts(start, end, n): expected 3 args, got {}",
1044                    arg_list.len()
1045                ));
1046            }
1047            let start = parse_num_arg(arg_list[0], "linear_starts.start")?;
1048            let end = parse_num_arg(arg_list[1], "linear_starts.end")?;
1049            let n = parse_u64_arg(arg_list[2], "linear_starts.n")?;
1050            Ok(Some(generate_linear_points(start, end, n, false)))
1051        }
1052        "linear_steps" => {
1053            if arg_list.len() != 3 {
1054                return Err(format!(
1055                    "linear_steps(start, end, n): expected 3 args, got {}",
1056                    arg_list.len()
1057                ));
1058            }
1059            let start = parse_num_arg(arg_list[0], "linear_steps.start")?;
1060            let end = parse_num_arg(arg_list[1], "linear_steps.end")?;
1061            let n = parse_u64_arg(arg_list[2], "linear_steps.n")?;
1062            Ok(Some(generate_linear_points(start, end, n, true)))
1063        }
1064        "log_steps" => {
1065            if arg_list.len() != 3 {
1066                return Err(format!(
1067                    "log_steps(start, end, n): expected 3 args, got {}",
1068                    arg_list.len()
1069                ));
1070            }
1071            let start = parse_num_arg(arg_list[0], "log_steps.start")?;
1072            let end = parse_num_arg(arg_list[1], "log_steps.end")?;
1073            let n = parse_u64_arg(arg_list[2], "log_steps.n")?;
1074            Ok(Some(generate_log_steps(start, end, n)?))
1075        }
1076        _ => Ok(None),
1077    }
1078}
1079
1080/// First `n` Fibonacci numbers: 1, 1, 2, 3, 5, 8, ...
1081fn generate_fib_n(n: u64) -> Vec<Value> {
1082    if n == 0 {
1083        return Vec::new();
1084    }
1085    let mut out = Vec::with_capacity(n as usize);
1086    let (mut a, mut b): (u64, u64) = (1, 1);
1087    for _ in 0..n {
1088        out.push(Value::U64(a));
1089        let next = a.saturating_add(b);
1090        a = b;
1091        b = next;
1092    }
1093    out
1094}
1095
1096/// Fibonacci values up to and including the largest ≤ `max`.
1097fn generate_fib_until(max: u64) -> Vec<Value> {
1098    let mut out = Vec::new();
1099    let (mut a, mut b): (u64, u64) = (1, 1);
1100    while a <= max {
1101        out.push(Value::U64(a));
1102        let next = a.checked_add(b);
1103        a = b;
1104        match next {
1105            Some(v) => b = v,
1106            None => break,
1107        }
1108    }
1109    out
1110}
1111
1112/// `1, 2, 4, ..., 2^(n-1)`.
1113fn generate_pow2_n(n: u64) -> Vec<Value> {
1114    let mut out = Vec::with_capacity(n as usize);
1115    for i in 0..n {
1116        if i >= 64 {
1117            break;
1118        } // 2^64 overflows u64
1119        out.push(Value::U64(1u64 << i));
1120    }
1121    out
1122}
1123
1124/// Powers of two ≤ max.
1125fn generate_pow2_until(max: u64) -> Vec<Value> {
1126    let mut out = Vec::new();
1127    let mut v: u64 = 1;
1128    loop {
1129        if v > max {
1130            break;
1131        }
1132        out.push(Value::U64(v));
1133        v = match v.checked_mul(2) {
1134            Some(x) => x,
1135            None => break,
1136        };
1137    }
1138    out
1139}
1140
1141/// `start, start*factor, start*factor², …` (n terms).
1142fn generate_geometric(start: f64, factor: f64, n: u64) -> Vec<Value> {
1143    let mut out = Vec::with_capacity(n as usize);
1144    let mut v = start;
1145    for _ in 0..n {
1146        out.push(Value::F64(v));
1147        v *= factor;
1148    }
1149    out
1150}
1151
1152/// `start, start*factor, …` ≤ max.
1153fn generate_geometric_until(start: f64, factor: f64, max: f64) -> Vec<Value> {
1154    let mut out = Vec::new();
1155    let mut v = start;
1156    if factor <= 1.0 || start <= 0.0 || max <= 0.0 {
1157        // Defensive: avoid infinite loops with non-growing
1158        // factors. The "until" semantics implies growth.
1159        return out;
1160    }
1161    while v <= max {
1162        out.push(Value::F64(v));
1163        v *= factor;
1164    }
1165    out
1166}
1167
1168/// Binomial coefficients `C(n, 0), C(n, 1), …, C(n, n)`.
1169fn generate_binomial(n: u64) -> Vec<Value> {
1170    let mut out = Vec::with_capacity(n as usize + 1);
1171    let mut c: u128 = 1;
1172    out.push(Value::U64(1));
1173    for k in 1..=n {
1174        c = c * (n - k + 1) as u128 / k as u128;
1175        if c > u64::MAX as u128 {
1176            break;
1177        }
1178        out.push(Value::U64(c as u64));
1179    }
1180    out
1181}
1182
1183/// SRD 71: kernel-aware partition comprehension sources.
1184///
1185/// `subdivide(<ident>, n)` — resolve `<ident>` through the
1186/// kernel's scope chain to a `Partition` (typically an iter-var
1187/// bound by an enclosing `for:` clause) and split it into `n`
1188/// sub-partitions, same boundary math as the `subdivide(p, n)`
1189/// stdlib node and the `*/N` spec token:
1190///
1191/// ```yaml
1192/// - for: "outer in partitions(\"50%,*\", 1000)"
1193///   phases:
1194///     - for: "inner in subdivide(outer, 5)"
1195///       phases: [walk]
1196/// ```
1197///
1198/// When the ident does not resolve (synthesis-time
1199/// pre-evaluation probes the clause before the outer iteration
1200/// installs its value), a single placeholder partition is
1201/// returned so iter-var type detection still classifies the
1202/// variable as `ext`. At runtime dispatch the value is always
1203/// installed; a still-unresolved ident there falls out as an
1204/// unresolved-clause error downstream, never a silent empty
1205/// iteration.
1206fn try_eval_partition_call(text: &str, kernel: &dyn Lookup) -> Result<Option<Vec<Value>>, String> {
1207    let Some((name, args)) = parse_func_call(text) else {
1208        return Ok(None);
1209    };
1210    let arg_list = split_args_top_level(args);
1211    match name {
1212        "subdivide" => {
1213            if arg_list.len() != 2 {
1214                return Err(format!(
1215                    "subdivide(p, n): expected 2 arguments (a partition and a count), got {}",
1216                    arg_list.len()
1217                ));
1218            }
1219            let src = arg_list[0].trim();
1220            let n = parse_u64_arg(arg_list[1], "subdivide.n")?;
1221            let Some(value) = kernel.lookup(src) else {
1222                // Pre-evaluation probe: the outer iter-var isn't
1223                // installed yet. Return one placeholder so the clause's
1224                // iter-var type-detects as `ext`; real values arrive at
1225                // runtime dispatch.
1226                let placeholder = crate::iteration::cursor_partition::Partition {
1227                    idx: 0,
1228                    count: 1,
1229                    start_ord: 0,
1230                    end_ord: 1,
1231                    start_pct: 0.0,
1232                    end_pct: 100.0,
1233                    base_extent: 1,
1234                };
1235                return Ok(Some(vec![Value::from_partition(placeholder)]));
1236            };
1237            let Some(p) = value.as_partition().copied() else {
1238                return Err(format!(
1239                    "subdivide({src}, {n}): `{src}` resolved to {} — expected a \
1240                     Partition value (an iter-var from `for: \"p in partitions(...)\"` \
1241                     or a cursor's `.cursor` projection)",
1242                    value.to_display_string(),
1243                ));
1244            };
1245            let subs = crate::iteration::cursor_partition::subdivide_partition(&p, n)?;
1246            Ok(Some(subs.into_iter().map(Value::from_partition).collect()))
1247        }
1248        // SRD-71 grammar-position desugaring of an explicit
1249        // `partitions(spec, [extent])` source. The spec string is in a
1250        // comprehension position, so it is parsed + resolved HERE, on the
1251        // Result path — a bad spec (over-sum list, bad recipe/order/window,
1252        // malformed tail) surfaces a clean comprehension error rather than the
1253        // `partitions()` node's eval-time `panic!` (which const-fold swallows
1254        // into a misleading downstream type mismatch). The node is unchanged;
1255        // a spec in comprehension position simply never reaches its eval.
1256        // Default extent 100 (pct space) matches the node; the cursor's
1257        // `over p` re-scales each partition to its declared range.
1258        "partitions" => {
1259            if arg_list.is_empty() || arg_list.len() > 2 {
1260                return Err(format!(
1261                    "partitions(spec, [extent]): expected 1 or 2 arguments, got {}",
1262                    arg_list.len(),
1263                ));
1264            }
1265            let spec = resolve_partition_spec_arg(arg_list[0], kernel)?;
1266            let extent = match arg_list.get(1) {
1267                Some(a) => parse_u64_arg(a, "partitions.extent")?,
1268                None => 100,
1269            };
1270            desugar_partition_spec(&spec, extent, "comprehension source `partitions(...)`")
1271                .map(Some)
1272        }
1273        // Profile-driven partition source: `profile_partitions(dataset,
1274        // pattern)` cuts the dataset's vector space at the cumulative
1275        // sizes of the profiles matching `pattern`, one partition per
1276        // masked tier (see `library::vectors::build_profile_partitions`).
1277        // Resolved here (like `partitions`/`subdivide`) so the iter-var
1278        // type-detects as a partition even when the dataset can't be
1279        // const-folded at compile time: a resolvable group yields the
1280        // real tiers; an unresolvable one (a compile-time probe, or a
1281        // catalog miss surfaced later by the prebuffer) yields a single
1282        // placeholder so the iter-var still types as `ext`.
1283        "profile_partitions" => {
1284            #[cfg(not(feature = "vectordata"))]
1285            {
1286                Err("profile_partitions requires the `vectordata` Cargo feature".to_string())
1287            }
1288
1289            #[cfg(feature = "vectordata")]
1290            {
1291                if arg_list.len() != 2 {
1292                    return Err(format!(
1293                        "profile_partitions(dataset, pattern): expected 2 arguments, got {}",
1294                        arg_list.len()
1295                    ));
1296                }
1297                // Both args are literal strings after `{...}` interpolation;
1298                // strip matching outer quotes.
1299                let strip = |s: &str| -> String {
1300                    let s = s.trim();
1301                    let b = s.as_bytes();
1302                    if b.len() >= 2 && (b[0] == b'\'' || b[0] == b'"') && b[b.len() - 1] == b[0] {
1303                        s[1..s.len() - 1].to_string()
1304                    } else {
1305                        s.to_string()
1306                    }
1307                };
1308                let dataset = strip(arg_list[0]);
1309                let pattern = strip(arg_list[1]);
1310                match crate::library::vectors::load_dataset_group(&dataset) {
1311                    Ok(group) => {
1312                        let parts =
1313                            crate::library::vectors::build_profile_partitions(&group, &pattern);
1314                        Ok(Some(parts.into_iter().map(Value::from_partition).collect()))
1315                    }
1316                    Err(_) => {
1317                        // Probe / dataset unavailable: one placeholder so the
1318                        // clause's iter-var type-detects as `ext`. Real tiers
1319                        // arrive once the catalog resolves the group.
1320                        let placeholder = crate::iteration::cursor_partition::Partition {
1321                            idx: 0,
1322                            count: 1,
1323                            start_ord: 0,
1324                            end_ord: 1,
1325                            start_pct: 0.0,
1326                            end_pct: 100.0,
1327                            base_extent: 1,
1328                        };
1329                        Ok(Some(vec![Value::from_partition(placeholder)]))
1330                    }
1331                }
1332            }
1333        }
1334        _ => Ok(None),
1335    }
1336}
1337
1338/// SRD-71 comprehension-position desugaring: a `<ident>.partitions`
1339/// source (the primary operator sweep flow, `for: "p in cursor.partitions"`).
1340///
1341/// In comprehension position a *string* spec desugars per the partition
1342/// grammar. `<ident>.partitions` resolves `<ident>` against the kernel chain
1343/// to its spec string — a workload param such as `cursor=linear:4` — and the
1344/// `.partitions` projection selects the partition-spec desugaring (as opposed
1345/// to the string→token-list desugaring a bare string source would get),
1346/// expanding it into the same `PartitionList` that `partitions(spec)` yields.
1347/// Resolution uses the `partitions(spec)` node's default extent (100, pct
1348/// space); the cursor's `over p` clause re-scales each partition's percentages
1349/// to its actual declared range.
1350///
1351/// This MUST live here (not in `eval_const_expr`, which is kernel-less and
1352/// resolves the `cursor.partitions` field-access to `None`): only the
1353/// comprehension eval has the kernel needed to look the param up.
1354///
1355/// Returns `Ok(None)` when `text` is not a `<ident>.partitions` form. When the
1356/// ident does not resolve (a pre-evaluation probe before the value is
1357/// installed), a single placeholder partition is returned so iter-var type
1358/// detection still lands on `ext` — the same contract as
1359/// [`try_eval_partition_call`].
1360fn try_eval_param_partitions(
1361    text: &str,
1362    kernel: &dyn Lookup,
1363) -> Result<Option<Vec<Value>>, String> {
1364    let Some(ident) = text.trim().strip_suffix(".partitions") else {
1365        return Ok(None);
1366    };
1367    let ident = ident.trim();
1368    if !is_single_bare_ident(ident) {
1369        return Ok(None);
1370    }
1371    let Some(value) = kernel.lookup(ident) else {
1372        // Pre-eval probe: the param value isn't installed yet. Return one
1373        // placeholder so the clause's iter-var type-detects as `ext`.
1374        let placeholder = crate::iteration::cursor_partition::Partition {
1375            idx: 0,
1376            count: 1,
1377            start_ord: 0,
1378            end_ord: 1,
1379            start_pct: 0.0,
1380            end_pct: 100.0,
1381            base_extent: 1,
1382        };
1383        return Ok(Some(vec![Value::from_partition(placeholder)]));
1384    };
1385    // Already a resolved PartitionList → unpack directly.
1386    if let Some(list) = value.as_partition_list() {
1387        return Ok(Some(
1388            list.as_slice()
1389                .iter()
1390                .map(|p| Value::from_partition(*p))
1391                .collect(),
1392        ));
1393    }
1394    // Otherwise it must be a spec string — desugar it per the SRD-71 grammar.
1395    let Value::Str(spec) = &value else {
1396        return Err(format!(
1397            "comprehension source `{ident}.partitions`: `{ident}` resolved to \
1398             {} — expected a partition-spec string (a workload param such as \
1399             `cursor=linear:4`) or a PartitionList.",
1400            value.to_display_string(),
1401        ));
1402    };
1403    desugar_partition_spec(
1404        spec,
1405        100,
1406        &format!("comprehension source `{ident}.partitions`"),
1407    )
1408    .map(Some)
1409}
1410
1411/// Parse + resolve a partition spec string into its unpacked partition
1412/// values, on the Result path. Shared by the comprehension-position
1413/// desugaring forms (`<ident>.partitions` and `partitions("...")`): a bad
1414/// spec surfaces a clean error labelled by `ctx` HERE — it never reaches the
1415/// `partitions()` node's eval-time `panic!`. This is a grammar-position
1416/// concern (SRD-71), so spec validation lives where the spec is recognized.
1417fn desugar_partition_spec(spec: &str, extent: u64, ctx: &str) -> Result<Vec<Value>, String> {
1418    let parsed = crate::iteration::cursor_partition::parse(spec)
1419        .map_err(|e| format!("{ctx}: bad spec `{spec}`: {e}"))?;
1420    let parts = crate::iteration::cursor_partition::resolve(&parsed, 0, extent)
1421        .map_err(|e| format!("{ctx}: resolve failed for `{spec}`: {e}"))?;
1422    Ok(parts.into_iter().map(Value::from_partition).collect())
1423}
1424
1425/// Resolve a `partitions(...)` spec argument to its string form: a quoted
1426/// string literal yields its inner text; a bare identifier resolves against
1427/// the kernel chain to its string value; anything else is taken verbatim (an
1428/// unquoted spec such as a raw percentage list).
1429fn resolve_partition_spec_arg(arg: &str, kernel: &dyn Lookup) -> Result<String, String> {
1430    let a = arg.trim();
1431    if a.len() >= 2
1432        && ((a.starts_with('"') && a.ends_with('"')) || (a.starts_with('\'') && a.ends_with('\'')))
1433    {
1434        return Ok(a[1..a.len() - 1].to_string());
1435    }
1436    if is_single_bare_ident(a) {
1437        return match kernel.lookup(a) {
1438            Some(Value::Str(s)) => Ok(s.to_string()),
1439            Some(other) => Err(format!(
1440                "partitions(...): `{a}` resolved to {} — expected a spec string",
1441                other.to_display_string(),
1442            )),
1443            None => Err(format!(
1444                "partitions(...): `{a}` did not resolve to a spec string in scope"
1445            )),
1446        };
1447    }
1448    Ok(a.to_string())
1449}
1450
1451/// Evenly spaced numeric points over `[start, end]`.
1452///
1453/// Half-open form (`linear_starts`): the start of each of `n`
1454/// equal subdivisions of `[start, end)` — `end` is never
1455/// emitted. Inclusive form (`linear_steps`): `n` fence-post
1456/// points covering `[start, end]`, both ends emitted.
1457///
1458/// These yield *values*, not partitions; splitting a
1459/// `Partition` into sub-partitions is `subdivide(p, n)` in the
1460/// partition stdlib (SRD 71).
1461fn generate_linear_points(start: f64, end: f64, n: u64, inclusive: bool) -> Vec<Value> {
1462    if n == 0 {
1463        return Vec::new();
1464    }
1465    let denom = if inclusive {
1466        (n.saturating_sub(1)).max(1) as f64
1467    } else {
1468        n as f64
1469    };
1470    let step = (end - start) / denom;
1471    (0..n)
1472        .map(|i| Value::F64(start + step * i as f64))
1473        .collect()
1474}
1475
1476/// `n` log-spaced points from `start` to `end` (inclusive).
1477/// Both bounds must be positive (log undefined otherwise).
1478fn generate_log_steps(start: f64, end: f64, n: u64) -> Result<Vec<Value>, String> {
1479    if start <= 0.0 || end <= 0.0 {
1480        return Err(format!(
1481            "log_steps: bounds must be positive, got start={start}, end={end}"
1482        ));
1483    }
1484    if n == 0 {
1485        return Ok(Vec::new());
1486    }
1487    if n == 1 {
1488        return Ok(vec![Value::F64(start)]);
1489    }
1490    let log_s = start.ln();
1491    let log_e = end.ln();
1492    let step = (log_e - log_s) / (n - 1) as f64;
1493    Ok((0..n)
1494        .map(|i| Value::F64((log_s + step * i as f64).exp()))
1495        .collect())
1496}
1497
1498// ============================================================
1499// SRD-18c Layer 5 / SRD-18e Push 9: set operators
1500// ============================================================
1501
1502/// Recognise `concat(...)`, `unique(...)`, etc. Each set op
1503/// recursively evaluates its arguments through `evaluate_spec`
1504/// (so `concat(1..10, fib(8))` works), then combines the
1505/// resulting lists.
1506fn try_eval_setop(text: &str, kernel: &dyn Lookup) -> Result<Option<Vec<Value>>, String> {
1507    let Some((name, args)) = parse_func_call(text) else {
1508        return Ok(None);
1509    };
1510    let arg_texts = split_args_top_level(args);
1511    let recursively_evaluate = |t: &str| -> Result<Vec<Value>, String> {
1512        evaluate_spec(t, kernel).map_err(|e| e.to_string())
1513    };
1514    match name {
1515        "concat" => {
1516            let mut out = Vec::new();
1517            for a in &arg_texts {
1518                out.extend(recursively_evaluate(a)?);
1519            }
1520            Ok(Some(out))
1521        }
1522        "unique" => {
1523            let mut out: Vec<Value> = Vec::new();
1524            for a in &arg_texts {
1525                for v in recursively_evaluate(a)? {
1526                    if !out.contains(&v) {
1527                        out.push(v);
1528                    }
1529                }
1530            }
1531            Ok(Some(out))
1532        }
1533        "intersect" => {
1534            if arg_texts.is_empty() {
1535                return Ok(Some(Vec::new()));
1536            }
1537            let first = recursively_evaluate(arg_texts[0])?;
1538            let mut out: Vec<Value> = Vec::new();
1539            for v in first {
1540                let mut in_all = true;
1541                for a in &arg_texts[1..] {
1542                    let other = recursively_evaluate(a)?;
1543                    if !other.contains(&v) {
1544                        in_all = false;
1545                        break;
1546                    }
1547                }
1548                if in_all && !out.contains(&v) {
1549                    out.push(v);
1550                }
1551            }
1552            Ok(Some(out))
1553        }
1554        "subtract" => {
1555            if arg_texts.len() != 2 {
1556                return Err(format!(
1557                    "subtract(a, b): expected 2 args, got {}",
1558                    arg_texts.len()
1559                ));
1560            }
1561            let a = recursively_evaluate(arg_texts[0])?;
1562            let b = recursively_evaluate(arg_texts[1])?;
1563            Ok(Some(a.into_iter().filter(|v| !b.contains(v)).collect()))
1564        }
1565        "interleave" => {
1566            let lists: Result<Vec<Vec<Value>>, String> =
1567                arg_texts.iter().map(|a| recursively_evaluate(a)).collect();
1568            let lists = lists?;
1569            let mut out = Vec::new();
1570            let max_len = lists.iter().map(|l| l.len()).max().unwrap_or(0);
1571            for i in 0..max_len {
1572                for l in &lists {
1573                    if let Some(v) = l.get(i) {
1574                        out.push(v.clone());
1575                    }
1576                }
1577            }
1578            Ok(Some(out))
1579        }
1580        "cycle" => {
1581            if arg_texts.len() != 2 {
1582                return Err(format!(
1583                    "cycle(a, n): expected 2 args, got {}",
1584                    arg_texts.len()
1585                ));
1586            }
1587            let a = recursively_evaluate(arg_texts[0])?;
1588            let n = parse_u64_arg(arg_texts[1], "cycle.n")?;
1589            let mut out = Vec::with_capacity(a.len() * n as usize);
1590            for _ in 0..n {
1591                out.extend(a.iter().cloned());
1592            }
1593            Ok(Some(out))
1594        }
1595        "reverse" => {
1596            if arg_texts.len() != 1 {
1597                return Err(format!(
1598                    "reverse(a): expected 1 arg, got {}",
1599                    arg_texts.len()
1600                ));
1601            }
1602            let mut a = recursively_evaluate(arg_texts[0])?;
1603            a.reverse();
1604            Ok(Some(a))
1605        }
1606        "take" => {
1607            if arg_texts.len() != 2 {
1608                return Err(format!(
1609                    "take(a, n): expected 2 args, got {}",
1610                    arg_texts.len()
1611                ));
1612            }
1613            let a = recursively_evaluate(arg_texts[0])?;
1614            let n = parse_u64_arg(arg_texts[1], "take.n")?;
1615            Ok(Some(a.into_iter().take(n as usize).collect()))
1616        }
1617        "skip" => {
1618            if arg_texts.len() != 2 {
1619                return Err(format!(
1620                    "skip(a, n): expected 2 args, got {}",
1621                    arg_texts.len()
1622                ));
1623            }
1624            let a = recursively_evaluate(arg_texts[0])?;
1625            let n = parse_u64_arg(arg_texts[1], "skip.n")?;
1626            Ok(Some(a.into_iter().skip(n as usize).collect()))
1627        }
1628        _ => Ok(None),
1629    }
1630}
1631
1632// ============================================================
1633// SRD-18c §"Sequencer-style expansions" / Push 8: bucket /
1634// concat_seq / interval_seq — LUT facility reusing the
1635// op-sequencing algorithms.
1636// ============================================================
1637
1638/// Recognise `bucket(items, ratios)` / `bucket("3:a, 1:b")`,
1639/// `concat_seq(...)`, `interval_seq(...)`. Reuses the
1640/// algorithms from the host's op-sequencing.
1641///
1642/// The algorithms aren't exposed cross-crate as raw functions
1643/// today, so we re-implement the small set we need here. The
1644/// outputs match `build_bucket_lut` / `build_concat_lut` /
1645/// `build_interval_lut` byte-for-byte (covered by the
1646/// the host's op-sequencing tests).
1647fn try_eval_sequencer(text: &str, kernel: &dyn Lookup) -> Result<Option<Vec<Value>>, String> {
1648    let Some((name, args)) = parse_func_call(text) else {
1649        return Ok(None);
1650    };
1651    if !matches!(name, "bucket" | "concat_seq" | "interval_seq") {
1652        return Ok(None);
1653    }
1654    let arg_texts = split_args_top_level(args);
1655
1656    // Two acceptable shapes:
1657    //   1. Single string arg: ratio-prefix shorthand
1658    //      `"3:ann, 1:scan, 2:fetch"`.
1659    //   2. Two list args: items + ratios in lockstep.
1660    let (items, ratios): (Vec<Value>, Vec<usize>) = match arg_texts.len() {
1661        1 => parse_ratio_prefix_shorthand(arg_texts[0])?,
1662        2 => {
1663            let items = evaluate_spec(arg_texts[0], kernel)?;
1664            let raw_ratios = evaluate_spec(arg_texts[1], kernel)?;
1665            let ratios: Result<Vec<usize>, String> = raw_ratios
1666                .iter()
1667                .map(|v| match v {
1668                    Value::U64(n) => Ok(*n as usize),
1669                    other => Err(format!(
1670                        "{name}: ratio must be non-negative integer, got {other:?}"
1671                    )),
1672                })
1673                .collect();
1674            (items, ratios?)
1675        }
1676        _ => {
1677            return Err(format!(
1678                "{name}: expected `(items, ratios)` or `(\"r1:item1, r2:item2, ...\")`; got {} args",
1679                arg_texts.len()
1680            ));
1681        }
1682    };
1683
1684    if items.len() != ratios.len() {
1685        return Err(format!(
1686            "{name}: items.len() ({}) != ratios.len() ({})",
1687            items.len(),
1688            ratios.len(),
1689        ));
1690    }
1691    Ok(Some(match name {
1692        "bucket" => seq_bucket(&items, &ratios),
1693        "concat_seq" => seq_concat(&items, &ratios),
1694        "interval_seq" => seq_interval(&items, &ratios),
1695        _ => unreachable!(),
1696    }))
1697}
1698
1699/// Parse `"r1:item1, r2:item2, …"`. Each element is a
1700/// ratio (positive integer) and an item value separated
1701/// by `:`. The string itself comes through `evaluate_spec`
1702/// — typically as a quoted string literal.
1703fn parse_ratio_prefix_shorthand(text: &str) -> Result<(Vec<Value>, Vec<usize>), String> {
1704    // The arg might be a literal `"3:a, 1:b"` (with quotes
1705    // in the source) or already-stripped `3:a, 1:b`.
1706    let stripped = text
1707        .trim()
1708        .trim_start_matches(['"', '\''])
1709        .trim_end_matches(['"', '\'']);
1710    let mut items = Vec::new();
1711    let mut ratios = Vec::new();
1712    for part in stripped.split(',') {
1713        let part = part.trim();
1714        if part.is_empty() {
1715            continue;
1716        }
1717        let (r, i) = part
1718            .split_once(':')
1719            .ok_or_else(|| format!("ratio-prefix shorthand: missing ':' in '{part}'"))?;
1720        let ratio: usize = r.trim().parse().map_err(|_| {
1721            format!("ratio-prefix shorthand: ratio '{r}' is not a non-negative integer")
1722        })?;
1723        ratios.push(ratio);
1724        items.push(parse_one_value(i.trim()));
1725    }
1726    Ok((items, ratios))
1727}
1728
1729fn parse_one_value(s: &str) -> Value {
1730    if let Ok(n) = s.parse::<u64>() {
1731        return Value::U64(n);
1732    }
1733    if let Ok(f) = s.parse::<f64>() {
1734        return Value::F64(f);
1735    }
1736    if s == "true" {
1737        return Value::Bool(true);
1738    }
1739    if s == "false" {
1740        return Value::Bool(false);
1741    }
1742    Value::Str(s.to_string().into())
1743}
1744
1745/// Bucket sequencer: round-robin from per-item buckets sized
1746/// by ratio. Output length = sum(ratios).
1747fn seq_bucket(items: &[Value], ratios: &[usize]) -> Vec<Value> {
1748    let total: usize = ratios.iter().sum();
1749    let mut out = Vec::with_capacity(total);
1750    let mut remaining: Vec<usize> = ratios.to_vec();
1751    while out.len() < total {
1752        let mut emitted_any = false;
1753        for (i, item) in items.iter().enumerate() {
1754            if remaining[i] > 0 {
1755                out.push(item.clone());
1756                remaining[i] -= 1;
1757                emitted_any = true;
1758            }
1759        }
1760        if !emitted_any {
1761            break;
1762        }
1763    }
1764    out
1765}
1766
1767/// Concat sequencer: contiguous runs (all of item 1, then
1768/// all of item 2, …).
1769fn seq_concat(items: &[Value], ratios: &[usize]) -> Vec<Value> {
1770    let total: usize = ratios.iter().sum();
1771    let mut out = Vec::with_capacity(total);
1772    for (item, &r) in items.iter().zip(ratios.iter()) {
1773        for _ in 0..r {
1774            out.push(item.clone());
1775        }
1776    }
1777    out
1778}
1779
1780/// Interval sequencer: evenly spaced occurrences of each
1781/// item across the output. Picks each output position from
1782/// the item with the largest "weight × position - already
1783/// emitted" — same algorithm as op-sequencing's
1784/// build_interval_lut.
1785fn seq_interval(items: &[Value], ratios: &[usize]) -> Vec<Value> {
1786    let total: usize = ratios.iter().sum();
1787    if total == 0 {
1788        return Vec::new();
1789    }
1790    let mut emitted: Vec<usize> = vec![0; items.len()];
1791    let mut out = Vec::with_capacity(total);
1792    for slot in 0..total {
1793        // Pick the item whose target ratio is most under-met
1794        // at this slot. Target at slot k = (ratio_i * (k+1)) / total.
1795        let mut best = 0usize;
1796        let mut best_deficit: f64 = f64::NEG_INFINITY;
1797        for i in 0..items.len() {
1798            let target = ratios[i] as f64 * (slot + 1) as f64 / total as f64;
1799            let deficit = target - emitted[i] as f64;
1800            if deficit > best_deficit {
1801                best_deficit = deficit;
1802                best = i;
1803            }
1804        }
1805        out.push(items[best].clone());
1806        emitted[best] += 1;
1807    }
1808    out
1809}
1810
1811/// Map a `Value` to the canonical polydat extern type keyword.
1812///
1813/// Delegates to [`Value::port_type`] + [`PortType::to_keyword`](crate::ast::PortType::to_keyword) —
1814/// the single source of truth for the str↔PortType table. The
1815/// returned keyword round-trips byte-cleanly through
1816/// [`PortType::from_keyword`](crate::ast::PortType::from_keyword) in the DSL extern parser, so every
1817/// typed `Value` variant (including `VecF32`, `Bytes`, `Json`,
1818/// `Handle`) becomes a precisely-typed input on the synthesized
1819/// inner kernel.
1820pub fn value_to_polydat_type_name(v: &Value) -> &'static str {
1821    v.port_type().to_keyword()
1822}
1823
1824/// Enumerate the typed tuples a Cartesian comprehension produces.
1825///
1826/// Walks the dependent-tuple tree depth-first using fresh
1827/// per-branch kernels. Each branch installs the prior clauses'
1828/// typed values as inputs on a fresh kernel
1829/// (`PolydatKernel::from_program` + `PolydatKernel::materialize_wiring_from_outer`),
1830/// then evaluates the next clause's spec against that kernel.
1831/// This is the kernel-per-logical-subspace rule from SRD-18b
1832/// §"Dependent Tuple Iteration".
1833///
1834/// `filter`, when provided, is evaluated against each fully-bound
1835/// tuple — the predicate text is interpolated against a kernel
1836/// with all clause values installed, then `eval_const_expr` runs
1837/// it to a `Value::Bool`. Tuples where the predicate is `false`
1838/// are skipped. Predicate evaluation errors (non-Bool result,
1839/// unresolved name, etc.) abort enumeration. See
1840/// [`Comprehension::filter`](super::ast_legacy::Comprehension::filter).
1841///
1842/// Empty-clause handling is delegated to `on_empty_clause`: the
1843/// caller decides whether to propagate as a hard error (strict
1844/// mode) or warn-and-skip (relaxed mode). The callback receives
1845/// the offending `Clause` (which carries both single-var and
1846/// parallel-iter shapes) and returns `Result<(), String>` —
1847/// returning `Err` aborts enumeration, `Ok(())` skips the
1848/// branch.
1849pub fn enumerate_tuples<F>(
1850    canonical: &Arc<PolydatKernel>,
1851    parent: &Arc<PolydatKernel>,
1852    clauses: &[super::ast_legacy::Clause],
1853    filter: Option<&str>,
1854    mut on_empty_clause: F,
1855) -> Result<Vec<Vec<(String, Value)>>, String>
1856where
1857    F: FnMut(&super::ast_legacy::Clause) -> Result<(), String>,
1858{
1859    let mut out = Vec::new();
1860    enumerate_into(
1861        canonical,
1862        parent,
1863        clauses,
1864        filter,
1865        0,
1866        &Vec::new(),
1867        &mut out,
1868        &mut on_empty_clause,
1869    )?;
1870    Ok(out)
1871}
1872
1873#[allow(clippy::too_many_arguments)]
1874fn enumerate_into<F>(
1875    canonical: &Arc<PolydatKernel>,
1876    parent: &Arc<PolydatKernel>,
1877    clauses: &[super::ast_legacy::Clause],
1878    filter: Option<&str>,
1879    idx: usize,
1880    prefix: &[(String, Value)],
1881    out: &mut Vec<Vec<(String, Value)>>,
1882    on_empty_clause: &mut F,
1883) -> Result<(), String>
1884where
1885    F: FnMut(&super::ast_legacy::Clause) -> Result<(), String>,
1886{
1887    use super::ast_legacy::ClauseSource;
1888
1889    if idx == clauses.len() {
1890        // Apply the filter, if any, against a fresh kernel with
1891        // every tuple value installed. If the predicate evaluates
1892        // to false, skip this tuple; if true (or filter absent),
1893        // emit it.
1894        if let Some(predicate) = filter {
1895            // Iter-var values from the prefix flow through the
1896            // parent's typed materialize step; this also gives
1897            // the cell cascade the prefix snapshot before the
1898            // bind, matching for_iteration's contract.
1899            let bindings_owned: Vec<(String, Value)> = prefix
1900                .iter()
1901                .map(|(v, val)| ((*v).to_string(), val.clone()))
1902                .collect();
1903            let kernel = parent.materialize_subscope(canonical.program().clone(), &bindings_owned);
1904            let interpolated = interpolate_via_kernel(predicate, &kernel)
1905                .map_err(|e| format!("comprehension filter '{predicate}': {e}"))?;
1906            let result = crate::dsl::compile::eval_const_expr(&interpolated)
1907                .map_err(|e| format!("comprehension filter '{predicate}': {e}"))?;
1908            // Polydat comparison operators return U64 (0/1); accept
1909            // any truthy/falsy scalar uniformly, matching the
1910            // do-loop condition handler.
1911            let keep = match result {
1912                Value::Bool(b) => b,
1913                Value::U64(n) => n != 0,
1914                Value::F64(n) => n != 0.0,
1915                other => {
1916                    return Err(format!(
1917                        "comprehension filter '{predicate}': expected bool/u64/f64, got {other:?}"
1918                    ));
1919                }
1920            };
1921            if keep {
1922                out.push(prefix.to_vec());
1923            }
1924        } else {
1925            out.push(prefix.to_vec());
1926        }
1927        return Ok(());
1928    }
1929    let bindings_owned: Vec<(String, Value)> = prefix
1930        .iter()
1931        .map(|(v, val)| ((*v).to_string(), val.clone()))
1932        .collect();
1933    let kernel = parent.materialize_subscope(canonical.program().clone(), &bindings_owned);
1934
1935    let clause = &clauses[idx];
1936    match &clause.source {
1937        ClauseSource::Single(spec_text) => {
1938            let var = clause.var();
1939            let values = evaluate_spec(spec_text, &kernel)
1940                .map_err(|e| format!("for_each clause '{var} in {spec_text}': {e}"))?;
1941
1942            if values.is_empty() {
1943                on_empty_clause(clause)?;
1944                return Ok(());
1945            }
1946
1947            for value in values {
1948                let mut next_prefix = prefix.to_vec();
1949                next_prefix.push((var.to_string(), value));
1950                enumerate_into(
1951                    canonical,
1952                    parent,
1953                    clauses,
1954                    filter,
1955                    idx + 1,
1956                    &next_prefix,
1957                    out,
1958                    on_empty_clause,
1959                )?;
1960            }
1961        }
1962        ClauseSource::Parallel { mode, exprs } => {
1963            // Layer 7a: evaluate each expr in the group, then zip
1964            // them. The zip mode (Strict / Truncate / Cycle)
1965            // controls length-balancing; Strict is the default
1966            // for the bare `(e1, e2)` syntax.
1967            use super::ast_legacy::ZipMode;
1968            let group_label = format!(
1969                "({}) in {}({})",
1970                clause.vars.join(", "),
1971                match mode {
1972                    ZipMode::Strict => "",
1973                    ZipMode::Truncate => "zip_truncate",
1974                    ZipMode::Cycle => "zip_cycle",
1975                },
1976                exprs.join(", "),
1977            );
1978            let mut columns: Vec<Vec<Value>> = Vec::with_capacity(exprs.len());
1979            for expr in exprs {
1980                let values = evaluate_spec(expr, &kernel)
1981                    .map_err(|e| format!("for_each parallel clause '{group_label}': {e}"))?;
1982                columns.push(values);
1983            }
1984            let lens: Vec<usize> = columns.iter().map(|c| c.len()).collect();
1985            let len = match mode {
1986                ZipMode::Strict => {
1987                    let len0 = lens[0];
1988                    for (i, &l) in lens.iter().enumerate().skip(1) {
1989                        if l != len0 {
1990                            return Err(format!(
1991                                "for_each parallel clause '{group_label}': \
1992                                 length mismatch — expr 0 produced {len0} values, \
1993                                 expr {i} produced {l} (use zip_truncate(...) or \
1994                                 zip_cycle(...) to opt into truncate/cycle semantics)"
1995                            ));
1996                        }
1997                    }
1998                    len0
1999                }
2000                ZipMode::Truncate => *lens.iter().min().unwrap(),
2001                ZipMode::Cycle => {
2002                    // Reject empty columns under Cycle — there's
2003                    // no value to repeat. Fall through to the
2004                    // empty-clause callback below by using len=0.
2005                    if lens.contains(&0) {
2006                        0
2007                    } else {
2008                        *lens.iter().max().unwrap()
2009                    }
2010                }
2011            };
2012            if len == 0 {
2013                on_empty_clause(clause)?;
2014                return Ok(());
2015            }
2016            for step in 0..len {
2017                let mut next_prefix = prefix.to_vec();
2018                for (var, col) in clause.vars.iter().zip(columns.iter()) {
2019                    // Cycle: index modulo column length so shorter
2020                    // columns repeat; Strict / Truncate: direct.
2021                    let i = if matches!(mode, ZipMode::Cycle) {
2022                        step % col.len()
2023                    } else {
2024                        step
2025                    };
2026                    next_prefix.push((var.clone(), col[i].clone()));
2027                }
2028                enumerate_into(
2029                    canonical,
2030                    parent,
2031                    clauses,
2032                    filter,
2033                    idx + 1,
2034                    &next_prefix,
2035                    out,
2036                    on_empty_clause,
2037                )?;
2038            }
2039        }
2040    }
2041    Ok(())
2042}
2043
2044// Expand `{name}` placeholders in `text`, resolving each leaf
2045// placeholder against `kernel`'s in-scope name space.
2046//
2047// `interpolate_via_kernel`, `interpolate_with_lookup`, and the
2048// internal `one_pass` / `first_unresolved` / `unescape` helpers
2049// moved to `polydat::kernel::interp` in PR 9c-3 (Surface #5).
2050// `interpolate_via_kernel` and `interpolate_with_lookup` are
2051// imported above for internal use; external callers use the
2052// `polydat::kernel::interp` module directly.
2053
2054#[cfg(test)]
2055mod tests {
2056    use super::*;
2057
2058    fn h(pairs: &[(&str, &str)]) -> HashMap<String, String> {
2059        pairs
2060            .iter()
2061            .map(|(k, v)| (k.to_string(), v.to_string()))
2062            .collect()
2063    }
2064
2065    fn interpolate(
2066        text: &str,
2067        bindings: &HashMap<String, String>,
2068        workload_params: &HashMap<String, String>,
2069    ) -> Result<String, String> {
2070        interpolate_with_lookup(text, |name| {
2071            bindings
2072                .get(name)
2073                .or_else(|| workload_params.get(name))
2074                .cloned()
2075        })
2076    }
2077
2078    #[test]
2079    fn flat_substitution() {
2080        let params = h(&[("dataset", "example"), ("prefix", "label")]);
2081        let out = interpolate("matching('{dataset}', '{prefix}')", &h(&[]), &params).unwrap();
2082        assert_eq!(out, "matching('example', 'label')");
2083    }
2084
2085    #[test]
2086    fn bindings_shadow_params() {
2087        let params = h(&[("profile", "default")]);
2088        let bindings = h(&[("profile", "label_07")]);
2089        let out = interpolate("vec_{profile}", &bindings, &params).unwrap();
2090        assert_eq!(out, "vec_label_07");
2091    }
2092
2093    #[test]
2094    fn nested_placeholder_resolves_inside_out() {
2095        let params = h(&[("k_1_limits", "1,2,4,8"), ("k_10_limits", "10,20,30")]);
2096        let bindings = h(&[("k", "1")]);
2097        let out = interpolate("{k_{k}_limits}", &bindings, &params).unwrap();
2098        assert_eq!(out, "1,2,4,8");
2099    }
2100
2101    #[test]
2102    fn deeply_nested() {
2103        let params = h(&[("a_b_c", "WIN")]);
2104        let bindings = h(&[("x", "a"), ("y", "b"), ("z", "c")]);
2105        let out = interpolate("{{x}_{y}_{z}}", &bindings, &params).unwrap();
2106        assert_eq!(out, "WIN");
2107    }
2108
2109    #[test]
2110    fn escape_emits_literal_brace() {
2111        let out = interpolate("\\{not_a_var\\}", &h(&[]), &h(&[])).unwrap();
2112        assert_eq!(out, "{not_a_var}");
2113    }
2114
2115    #[test]
2116    fn escape_inside_otherwise_resolved_text() {
2117        let params = h(&[("x", "1")]);
2118        let out = interpolate("a={x} literal=\\{x\\}", &h(&[]), &params).unwrap();
2119        assert_eq!(out, "a=1 literal={x}");
2120    }
2121
2122    #[test]
2123    fn unresolved_is_hard_error() {
2124        let err = interpolate("hello {nope}", &h(&[]), &h(&[])).unwrap_err();
2125        assert!(err.contains("unresolved"));
2126        assert!(err.contains("nope"));
2127    }
2128
2129    #[test]
2130    fn empty_placeholder_rejected() {
2131        let err = interpolate("a{}b", &h(&[]), &h(&[])).unwrap_err();
2132        assert!(err.contains("empty"));
2133    }
2134
2135    #[test]
2136    fn unmatched_brace_rejected() {
2137        let err = interpolate("a {x", &h(&[]), &h(&[])).unwrap_err();
2138        assert!(err.contains("unmatched"));
2139    }
2140
2141    #[test]
2142    fn idempotent_when_no_placeholders() {
2143        let out = interpolate("plain text", &h(&[]), &h(&[])).unwrap();
2144        assert_eq!(out, "plain text");
2145    }
2146
2147    #[test]
2148    fn resolved_value_with_braces_does_not_re_expand() {
2149        let params = h(&[("greeting", "hello {planet}")]);
2150        let err = interpolate("{greeting}", &h(&[]), &params).unwrap_err();
2151        assert!(err.contains("planet"));
2152    }
2153
2154    #[test]
2155    fn cyclic_placeholders_hit_round_cap() {
2156        let params = h(&[("a", "{b}"), ("b", "{a}")]);
2157        let err = interpolate("{a}", &h(&[]), &params).unwrap_err();
2158        assert!(err.contains("did not stabilize") || err.contains("rounds"));
2159    }
2160
2161    #[test]
2162    fn kernel_resolves_via_get_constant() {
2163        let kernel =
2164            crate::dsl::compile::compile_polydat("const dataset := \"example\"\n").unwrap();
2165        let out = interpolate_via_kernel("path/{dataset}/data", &kernel).unwrap();
2166        assert_eq!(out, "path/example/data");
2167    }
2168
2169    #[test]
2170    fn kernel_resolves_via_get_input() {
2171        let parent = crate::dsl::compile::compile_polydat("const k_values := \"1, 10\"\n").unwrap();
2172        let child_program = crate::dsl::compile::compile_polydat("extern k_values: String\n")
2173            .unwrap()
2174            .program()
2175            .clone();
2176        let child = parent.materialize_subscope(child_program, &[]);
2177        let out = interpolate_via_kernel("values={k_values}", &child).unwrap();
2178        assert_eq!(out, "values=1, 10");
2179    }
2180
2181    #[test]
2182    fn kernel_unresolved_name_errors() {
2183        let kernel = crate::dsl::compile::compile_polydat("const x := 1\n").unwrap();
2184        let err = interpolate_via_kernel("hello {nope}", &kernel)
2185            .unwrap_err()
2186            .to_string();
2187        assert!(err.contains("unresolved"));
2188        assert!(err.contains("nope"));
2189    }
2190
2191    #[test]
2192    fn kernel_nested_template_iterates_to_fixed_point() {
2193        let kernel = crate::dsl::compile::compile_polydat(
2194            "const k := \"1\"\nconst k_1_limits := \"1, 2, 4, 8\"\n",
2195        )
2196        .unwrap();
2197        let out = interpolate_via_kernel("{k_{k}_limits}", &kernel).unwrap();
2198        assert_eq!(out, "1, 2, 4, 8");
2199    }
2200
2201    #[test]
2202    fn parse_list_native_types() {
2203        let v = parse_list_with_types("1, 10, 100");
2204        assert_eq!(v, vec![Value::U64(1), Value::U64(10), Value::U64(100)]);
2205    }
2206
2207    #[test]
2208    fn parse_list_mixed_types() {
2209        let v = parse_list_with_types("1, 1.5, true, hello");
2210        assert_eq!(
2211            v,
2212            vec![
2213                Value::U64(1),
2214                Value::F64(1.5),
2215                Value::Bool(true),
2216                Value::Str("hello".to_string().into()),
2217            ]
2218        );
2219    }
2220
2221    #[test]
2222    fn all_cursor_returns_extent_range() {
2223        // Simulate a cursor declaration at the parent scope by
2224        // exposing the auxiliary extent outputs as folded
2225        // constants. The real cursor compiler emits these via
2226        // `__cursor_extent_<name>_{start,end}` outputs; for this
2227        // test we synthesize them directly.
2228        let kernel = crate::dsl::compile::compile_polydat(
2229            "const __cursor_extent_row_start := 0\n\
2230             const __cursor_extent_row_end := 5\n",
2231        )
2232        .unwrap();
2233        let values = evaluate_spec("all(row)", &kernel).unwrap();
2234        assert_eq!(
2235            values,
2236            vec![
2237                Value::U64(0),
2238                Value::U64(1),
2239                Value::U64(2),
2240                Value::U64(3),
2241                Value::U64(4),
2242            ]
2243        );
2244    }
2245
2246    #[test]
2247    fn all_cursor_non_zero_start() {
2248        let kernel = crate::dsl::compile::compile_polydat(
2249            "const __cursor_extent_data_start := 100\n\
2250             const __cursor_extent_data_end := 103\n",
2251        )
2252        .unwrap();
2253        let values = evaluate_spec("all(data)", &kernel).unwrap();
2254        assert_eq!(
2255            values,
2256            vec![Value::U64(100), Value::U64(101), Value::U64(102)]
2257        );
2258    }
2259
2260    #[test]
2261    fn all_cursor_missing_extent_errors() {
2262        let kernel = crate::dsl::compile::compile_polydat("const unrelated := 1\n").unwrap();
2263        let err = evaluate_spec("all(no_such_cursor)", &kernel)
2264            .unwrap_err()
2265            .to_string();
2266        assert!(err.contains("all(no_such_cursor)"));
2267        assert!(err.contains("no resolvable extent"));
2268    }
2269
2270    #[test]
2271    fn all_cursor_only_matches_exact_shape() {
2272        // `all(<ident>)` is the only matched shape — anything
2273        // more complex falls through to the normal eval path.
2274        // `all(row, 5)` doesn't match the strict shape (the
2275        // comma breaks the bare-ident requirement), so the
2276        // pipeline tries to evaluate it as a regular GK
2277        // expression. There's no registered function named
2278        // `all`, so eval fails and the failure is propagated as
2279        // a clean clause-level error (the legacy silent
2280        // literal-list fallback masked this kind of typo six
2281        // layers downstream).
2282        let kernel = crate::dsl::compile::compile_polydat(
2283            "const __cursor_extent_row_start := 0\n\
2284             const __cursor_extent_row_end := 5\n",
2285        )
2286        .unwrap();
2287        let err = evaluate_spec("all(row, 5)", &kernel)
2288            .unwrap_err()
2289            .to_string();
2290        assert!(
2291            err.contains("all(row, 5)"),
2292            "error must mention the failing spec, got: {err}"
2293        );
2294        assert!(
2295            err.contains("failed to evaluate") || err.contains("unknown function"),
2296            "error must explain the eval failure, got: {err}"
2297        );
2298    }
2299
2300    #[test]
2301    fn missing_dataset_surface_as_clean_error_not_garbage() {
2302        // Regression: workload runs on a system whose
2303        // vectordata catalog doesn't have the requested
2304        // dataset. The spec
2305        //   `profile in matching_profiles('nonexistent_dataset_xyz', 'label_')`
2306        // must produce a clean clause-level error naming the
2307        // resolution failure — NOT a "garbage" iter-var like
2308        // `matching_profiles('nonexistent_dataset_xyz'`
2309        // (truncated at the first comma) that flows downstream
2310        // into malformed CQL six layers later.
2311        //
2312        // Three failure layers used to compound here:
2313        //   1. `dataset_group_open` returned `Value::None` on
2314        //      catalog miss.
2315        //   2. `handle_of(&Value::None)` panicked with
2316        //      "expected Handle, got U64" — opaque.
2317        //   3. `evaluate_spec` swallowed the eval error and
2318        //      fell through to splitting the literal text on
2319        //      commas.
2320        // The user-visible result was a CQL parser error from a
2321        // malformed `DROP INDEX`. After this fix every layer
2322        // propagates an actionable diagnostic.
2323        let kernel = crate::dsl::compile::compile_polydat("const unrelated := 1\n").unwrap();
2324        let result = evaluate_spec(
2325            "matching_profiles('nonexistent_dataset_xyz_qqq', 'label_')",
2326            &kernel,
2327        );
2328        let err = result
2329            .expect_err("missing dataset must surface as Err, not silent literal-list fallback")
2330            .to_string();
2331        // Doesn't matter which exact error string we get from
2332        // the catalog layer — the test guards the *contract*:
2333        // the spec text appears in the error, the failure is
2334        // attributed to the dataset / resolver / open path, and
2335        // it is a Result::Err (not garbage data).
2336        assert!(
2337            err.contains("nonexistent_dataset_xyz_qqq")
2338                || err.contains("matching_profiles")
2339                || err.contains("dataset"),
2340            "error must point at the actual fault, got: {err}"
2341        );
2342    }
2343
2344    #[test]
2345    fn function_call_eval_failure_is_not_silently_split() {
2346        // Defensive: any text containing `(` is an
2347        // expression — never a literal list. If eval fails, we
2348        // must propagate the failure rather than splitting on
2349        // commas. This guards the broader contract that
2350        // protected the dataset-resolution case above.
2351        let kernel = crate::dsl::compile::compile_polydat("const unrelated := 1\n").unwrap();
2352        let err = evaluate_spec("nonexistent_func('a', 'b', 'c')", &kernel)
2353            .unwrap_err()
2354            .to_string();
2355        assert!(
2356            err.contains("failed to evaluate") || err.contains("unknown"),
2357            "expected a clean eval-failure error, got: {err}"
2358        );
2359    }
2360
2361    #[test]
2362    fn literal_list_path_still_works() {
2363        // Counter-case: a plain comma-separated list of
2364        // literals (no parens, no operators) MUST still work
2365        // through the literal-list fallback after eval fails
2366        // (which it should — `1, 10, 100` isn't a single GK
2367        // expression). This is the legitimate use case that the
2368        // fallback exists for.
2369        let kernel = crate::dsl::compile::compile_polydat("const unrelated := 1\n").unwrap();
2370        let values = evaluate_spec("1, 10, 100", &kernel).unwrap();
2371        assert_eq!(values, vec![Value::U64(1), Value::U64(10), Value::U64(100)]);
2372
2373        let names = evaluate_spec("foo, bar, baz", &kernel).unwrap();
2374        assert_eq!(
2375            names,
2376            vec![
2377                Value::Str("foo".into()),
2378                Value::Str("bar".into()),
2379                Value::Str("baz".into()),
2380            ]
2381        );
2382    }
2383
2384    #[test]
2385    fn literal_cursor_exposes_extent_auxiliaries() {
2386        // Real cursor declaration with literal extent — verifies
2387        // the compiler-side change that emits
2388        // __cursor_extent_<name>_{start,end} as final bindings
2389        // even in the literal-args case.
2390        let kernel = crate::dsl::compile::compile_polydat("cursor row = range(0, 50)\n").unwrap();
2391        let start = kernel.lookup("__cursor_extent_row_start");
2392        let end = kernel.lookup("__cursor_extent_row_end");
2393        assert_eq!(
2394            start,
2395            Some(Value::U64(0)),
2396            "expected start=0, got {start:?}"
2397        );
2398        assert_eq!(end, Some(Value::U64(50)), "expected end=50, got {end:?}");
2399    }
2400
2401    #[test]
2402    fn all_cursor_with_real_cursor_decl_works() {
2403        let kernel = crate::dsl::compile::compile_polydat("cursor row = range(0, 5)\n").unwrap();
2404        let values = evaluate_spec("all(row)", &kernel).unwrap();
2405        assert_eq!(
2406            values,
2407            vec![
2408                Value::U64(0),
2409                Value::U64(1),
2410                Value::U64(2),
2411                Value::U64(3),
2412                Value::U64(4),
2413            ]
2414        );
2415    }
2416
2417    #[test]
2418    fn all_cursor_ignores_whitespace() {
2419        let kernel = crate::dsl::compile::compile_polydat(
2420            "const __cursor_extent_row_start := 0\n\
2421             const __cursor_extent_row_end := 3\n",
2422        )
2423        .unwrap();
2424        let values = evaluate_spec("  all( row )  ", &kernel).unwrap();
2425        assert_eq!(values.len(), 3);
2426    }
2427
2428    #[test]
2429    fn evaluate_spec_resolves_against_kernel() {
2430        let kernel =
2431            crate::dsl::compile::compile_polydat("const k_values := \"1, 10, 100\"\n").unwrap();
2432        let v = evaluate_spec("{k_values}", &kernel).unwrap();
2433        assert_eq!(v, vec![Value::U64(1), Value::U64(10), Value::U64(100)]);
2434    }
2435
2436    #[test]
2437    fn evaluate_spec_bare_ident_resolves_like_braced() {
2438        // SRD-18f Stage 2: a bare identifier source is a direct
2439        // wire/param reference — resolves identically to the
2440        // braced `{name}` interpolation form.
2441        let kernel =
2442            crate::dsl::compile::compile_polydat("const k_values := \"1, 10, 100\"\n").unwrap();
2443        let bare = evaluate_spec("k_values", &kernel).unwrap();
2444        let braced = evaluate_spec("{k_values}", &kernel).unwrap();
2445        assert_eq!(bare, braced);
2446        assert_eq!(bare, vec![Value::U64(1), Value::U64(10), Value::U64(100)]);
2447    }
2448
2449    #[test]
2450    fn evaluate_spec_unresolved_bare_is_error_with_quoting_hint() {
2451        // SRD-18f §6: a bare identifier source that doesn't
2452        // resolve is a hard error (not silently bound as its own
2453        // name-string), and the message points at the fix.
2454        let kernel = crate::dsl::compile::compile_polydat("\n").unwrap();
2455        let err = evaluate_spec("nonexistent", &kernel)
2456            .unwrap_err()
2457            .to_string();
2458        assert!(err.contains("did not resolve"), "got: {err}");
2459        assert!(err.contains("quote it"), "should hint quoting: {err}");
2460    }
2461
2462    #[test]
2463    fn bracket_list_spread_and_no_peel() {
2464        // `[xs…]` destructures (peels one level); `[xs]` binds the
2465        // whole value once.
2466        let kernel = crate::dsl::compile::compile_polydat("const xs := \"1, 2, 3\"\n").unwrap();
2467        // spread → peel the string's tokens
2468        let spread = evaluate_spec("[xs…]", &kernel).unwrap();
2469        assert_eq!(spread, vec![Value::U64(1), Value::U64(2), Value::U64(3)]);
2470        // no-peel → the whole value once (the string, un-striped)
2471        let whole = evaluate_spec("[xs]", &kernel).unwrap();
2472        assert_eq!(whole, vec![Value::Str("1, 2, 3".into())]);
2473    }
2474
2475    #[test]
2476    fn bracket_list_mixes_refs_literals_and_spread() {
2477        let kernel = crate::dsl::compile::compile_polydat("const mid := \"7, 8\"\n").unwrap();
2478        let v = evaluate_spec("[1, mid…, \"x\"]", &kernel).unwrap();
2479        assert_eq!(
2480            v,
2481            vec![
2482                Value::U64(1),
2483                Value::U64(7),
2484                Value::U64(8),
2485                Value::Str("x".into()),
2486            ]
2487        );
2488    }
2489
2490    // ── SRD-71: partition-list unpacking ─────────────────────
2491
2492    #[test]
2493    fn evaluate_spec_unpacks_partition_list_into_partition_values() {
2494        // `partitions("linear:3")` evaluates to a PartitionList
2495        // Ext value. evaluate_spec must unpack the list into a
2496        // Vec of individual Partition values so the for-clause
2497        // iterates partition-by-partition (one iteration per
2498        // partition).
2499        let kernel = empty_kernel();
2500        let v = evaluate_spec("partitions(\"linear:3\")", &kernel).unwrap();
2501        assert_eq!(v.len(), 3, "expected 3 partitions, got {}", v.len());
2502        for value in &v {
2503            assert!(
2504                value.as_partition().is_some(),
2505                "every iter value should be a Partition, got {value:?}"
2506            );
2507        }
2508    }
2509
2510    #[test]
2511    fn evaluate_spec_unpacks_partition_list_with_explicit_extent() {
2512        let kernel = empty_kernel();
2513        let v = evaluate_spec("partitions(\"fib:5\", 1000)", &kernel).unwrap();
2514        assert_eq!(v.len(), 5);
2515        // Partition indices increment from 0.
2516        for (i, value) in v.iter().enumerate() {
2517            let p = value.as_partition().unwrap();
2518            assert_eq!(p.idx, i as u64);
2519            assert_eq!(p.base_extent, 1000);
2520        }
2521    }
2522
2523    #[test]
2524    fn pre_evaluate_clause_returns_partition_values_for_partitions_call() {
2525        // Same as the evaluate_spec test above but via the
2526        // synthesis-side pre_evaluate_clause entry point.
2527        let kernel = empty_kernel();
2528        let v = pre_evaluate_clause(
2529            "partitions(\"linear:4\")",
2530            &kernel,
2531            &HashMap::new(),
2532            &HashMap::new(),
2533        )
2534        .unwrap();
2535        assert_eq!(v.len(), 4);
2536        for value in &v {
2537            assert!(
2538                value.as_partition().is_some(),
2539                "pre_evaluate_clause must unpack PartitionList, got {value:?}"
2540            );
2541        }
2542    }
2543
2544    #[test]
2545    fn value_to_polydat_type_name_returns_ext_for_partition_value() {
2546        // The for_each scope synthesizer uses this to emit
2547        // `extern <var>: <keyword>` for each iter-var. Ext-typed
2548        // values (Partition, PartitionSpec, PartitionList) must
2549        // declare as `ext` so the resulting input port is
2550        // PortType::Ext and downstream `over <iter-var>` clauses
2551        // see the right shape.
2552        let p = crate::iteration::cursor_partition::Partition {
2553            idx: 0,
2554            count: 1,
2555            start_ord: 0,
2556            end_ord: 10,
2557            start_pct: 0.0,
2558            end_pct: 100.0,
2559            base_extent: 10,
2560        };
2561        let v = Value::from_partition(p);
2562        assert_eq!(value_to_polydat_type_name(&v), "ext");
2563    }
2564
2565    // ── SRD-18c Layer 2 / SRD-18e Push 3: range operator ──
2566
2567    fn empty_kernel() -> PolydatKernel {
2568        crate::dsl::compile::compile_polydat("\n").unwrap()
2569    }
2570
2571    #[test]
2572    fn range_half_open_integer() {
2573        let v = evaluate_spec("1..5", &empty_kernel()).unwrap();
2574        assert_eq!(
2575            v,
2576            vec![Value::U64(1), Value::U64(2), Value::U64(3), Value::U64(4),]
2577        );
2578    }
2579
2580    #[test]
2581    fn range_inclusive_integer() {
2582        let v = evaluate_spec("1..=5", &empty_kernel()).unwrap();
2583        assert_eq!(
2584            v,
2585            vec![
2586                Value::U64(1),
2587                Value::U64(2),
2588                Value::U64(3),
2589                Value::U64(4),
2590                Value::U64(5),
2591            ]
2592        );
2593    }
2594
2595    #[test]
2596    fn range_with_step() {
2597        let v = evaluate_spec("0..100..10", &empty_kernel()).unwrap();
2598        assert_eq!(
2599            v,
2600            vec![
2601                Value::U64(0),
2602                Value::U64(10),
2603                Value::U64(20),
2604                Value::U64(30),
2605                Value::U64(40),
2606                Value::U64(50),
2607                Value::U64(60),
2608                Value::U64(70),
2609                Value::U64(80),
2610                Value::U64(90),
2611            ]
2612        );
2613    }
2614
2615    #[test]
2616    fn range_inclusive_with_step() {
2617        let v = evaluate_spec("0..=100..25", &empty_kernel()).unwrap();
2618        assert_eq!(
2619            v,
2620            vec![
2621                Value::U64(0),
2622                Value::U64(25),
2623                Value::U64(50),
2624                Value::U64(75),
2625                Value::U64(100),
2626            ]
2627        );
2628    }
2629
2630    #[test]
2631    fn range_float_step() {
2632        let v = evaluate_spec("0.0..=1.0..0.25", &empty_kernel()).unwrap();
2633        assert_eq!(v.len(), 5, "got {v:?}");
2634        if let [
2635            Value::F64(a),
2636            Value::F64(b),
2637            Value::F64(c),
2638            Value::F64(d),
2639            Value::F64(e),
2640        ] = v.as_slice()
2641        {
2642            assert!((a - 0.0).abs() < 1e-12);
2643            assert!((b - 0.25).abs() < 1e-12);
2644            assert!((c - 0.5).abs() < 1e-12);
2645            assert!((d - 0.75).abs() < 1e-12);
2646            assert!((e - 1.0).abs() < 1e-12);
2647        } else {
2648            panic!("expected 5 floats, got {v:?}");
2649        }
2650    }
2651
2652    #[test]
2653    fn range_empty_when_start_equals_end_half_open() {
2654        let v = evaluate_spec("5..5", &empty_kernel()).unwrap();
2655        assert!(v.is_empty(), "got {v:?}");
2656    }
2657
2658    #[test]
2659    fn range_inclusive_with_equal_bounds_emits_one() {
2660        let v = evaluate_spec("5..=5", &empty_kernel()).unwrap();
2661        assert_eq!(v, vec![Value::U64(5)]);
2662    }
2663
2664    #[test]
2665    fn range_with_si_suffix_bounds() {
2666        // Push 4 SI suffixes meet Push 3 ranges — full
2667        // composition.
2668        let v = evaluate_spec("1K..1K..200", &empty_kernel()).unwrap();
2669        assert!(v.is_empty(), "1K..1K with positive step → empty");
2670
2671        let v = evaluate_spec("0..1K..200", &empty_kernel()).unwrap();
2672        assert_eq!(
2673            v,
2674            vec![
2675                Value::U64(0),
2676                Value::U64(200),
2677                Value::U64(400),
2678                Value::U64(600),
2679                Value::U64(800),
2680            ]
2681        );
2682    }
2683
2684    #[test]
2685    fn range_zero_step_errors() {
2686        let err = evaluate_spec("1..10..0", &empty_kernel())
2687            .unwrap_err()
2688            .to_string();
2689        assert!(err.contains("step is zero"), "{err}");
2690    }
2691
2692    #[test]
2693    fn range_too_many_dotdot_errors() {
2694        let err = evaluate_spec("1..2..3..4", &empty_kernel())
2695            .unwrap_err()
2696            .to_string();
2697        assert!(err.contains("more than two `..`"), "{err}");
2698    }
2699
2700    #[test]
2701    fn range_inside_parens_doesnt_split() {
2702        // `range(1, 10)` — the dots inside the function
2703        // call shouldn't trigger range-splitting at top
2704        // depth (there are no `..` here anyway, but verify
2705        // paren-balanced text passes through cleanly).
2706        // Use a literal with internal parens to exercise
2707        // the depth tracking.
2708        let v = evaluate_spec("(1)..(5)", &empty_kernel()).unwrap();
2709        assert_eq!(v.len(), 4); // 1, 2, 3, 4
2710    }
2711
2712    #[test]
2713    fn range_step_with_inclusive_separator_errors() {
2714        let err = evaluate_spec("1..10..=2", &empty_kernel())
2715            .unwrap_err()
2716            .to_string();
2717        assert!(err.contains("step delimiter cannot be `..=`"), "{err}");
2718    }
2719
2720    #[test]
2721    fn range_with_kernel_referenced_bounds() {
2722        let kernel =
2723            crate::dsl::compile::compile_polydat("const lo := 5\nconst hi := 12\n").unwrap();
2724        let v = evaluate_spec("{lo}..{hi}", &kernel).unwrap();
2725        assert_eq!(
2726            v,
2727            vec![
2728                Value::U64(5),
2729                Value::U64(6),
2730                Value::U64(7),
2731                Value::U64(8),
2732                Value::U64(9),
2733                Value::U64(10),
2734                Value::U64(11),
2735            ]
2736        );
2737    }
2738
2739    // ── SRD-18c Layer 3 / SRD-18e Push 7: named generators ──
2740
2741    #[test]
2742    fn fib_n_first_eight() {
2743        let v = evaluate_spec("fib(8)", &empty_kernel()).unwrap();
2744        assert_eq!(
2745            v,
2746            vec![
2747                Value::U64(1),
2748                Value::U64(1),
2749                Value::U64(2),
2750                Value::U64(3),
2751                Value::U64(5),
2752                Value::U64(8),
2753                Value::U64(13),
2754                Value::U64(21),
2755            ]
2756        );
2757    }
2758
2759    #[test]
2760    fn fib_until_50() {
2761        let v = evaluate_spec("fib_until(50)", &empty_kernel()).unwrap();
2762        assert_eq!(
2763            v,
2764            vec![
2765                Value::U64(1),
2766                Value::U64(1),
2767                Value::U64(2),
2768                Value::U64(3),
2769                Value::U64(5),
2770                Value::U64(8),
2771                Value::U64(13),
2772                Value::U64(21),
2773                Value::U64(34),
2774            ]
2775        );
2776    }
2777
2778    #[test]
2779    fn pow2_n_six() {
2780        let v = evaluate_spec("pow2(6)", &empty_kernel()).unwrap();
2781        assert_eq!(
2782            v,
2783            vec![
2784                Value::U64(1),
2785                Value::U64(2),
2786                Value::U64(4),
2787                Value::U64(8),
2788                Value::U64(16),
2789                Value::U64(32),
2790            ]
2791        );
2792    }
2793
2794    #[test]
2795    fn pow2_until_100() {
2796        let v = evaluate_spec("pow2_until(100)", &empty_kernel()).unwrap();
2797        assert_eq!(
2798            v,
2799            vec![
2800                Value::U64(1),
2801                Value::U64(2),
2802                Value::U64(4),
2803                Value::U64(8),
2804                Value::U64(16),
2805                Value::U64(32),
2806                Value::U64(64),
2807            ]
2808        );
2809    }
2810
2811    #[test]
2812    fn binomial_n_5() {
2813        // C(5,0..5) = 1, 5, 10, 10, 5, 1
2814        let v = evaluate_spec("binomial(5)", &empty_kernel()).unwrap();
2815        assert_eq!(
2816            v,
2817            vec![
2818                Value::U64(1),
2819                Value::U64(5),
2820                Value::U64(10),
2821                Value::U64(10),
2822                Value::U64(5),
2823                Value::U64(1),
2824            ]
2825        );
2826    }
2827
2828    #[test]
2829    fn geometric_2_doubles_4_terms() {
2830        let v = evaluate_spec("geometric(1, 2, 4)", &empty_kernel()).unwrap();
2831        // Floats because factor is float-cast at eval.
2832        if let [Value::F64(a), Value::F64(b), Value::F64(c), Value::F64(d)] = v.as_slice() {
2833            assert!((a - 1.0).abs() < 1e-12);
2834            assert!((b - 2.0).abs() < 1e-12);
2835            assert!((c - 4.0).abs() < 1e-12);
2836            assert!((d - 8.0).abs() < 1e-12);
2837        } else {
2838            panic!("expected 4 f64 values, got {v:?}");
2839        }
2840    }
2841
2842    #[test]
2843    fn linear_starts_half_open_5_points() {
2844        let v = evaluate_spec("linear_starts(0, 100, 5)", &empty_kernel()).unwrap();
2845        // (100-0)/5 = 20 step. 0, 20, 40, 60, 80.
2846        if let [
2847            Value::F64(a),
2848            Value::F64(b),
2849            Value::F64(c),
2850            Value::F64(d),
2851            Value::F64(e),
2852        ] = v.as_slice()
2853        {
2854            assert!((a - 0.0).abs() < 1e-12);
2855            assert!((b - 20.0).abs() < 1e-12);
2856            assert!((c - 40.0).abs() < 1e-12);
2857            assert!((d - 60.0).abs() < 1e-12);
2858            assert!((e - 80.0).abs() < 1e-12);
2859        } else {
2860            panic!("got {v:?}");
2861        }
2862    }
2863
2864    #[test]
2865    fn linear_steps_inclusive_5_points() {
2866        let v = evaluate_spec("linear_steps(0, 100, 5)", &empty_kernel()).unwrap();
2867        // 0, 25, 50, 75, 100
2868        if let [
2869            Value::F64(a),
2870            Value::F64(b),
2871            Value::F64(c),
2872            Value::F64(d),
2873            Value::F64(e),
2874        ] = v.as_slice()
2875        {
2876            assert!((a - 0.0).abs() < 1e-12);
2877            assert!((b - 25.0).abs() < 1e-12);
2878            assert!((c - 50.0).abs() < 1e-12);
2879            assert!((d - 75.0).abs() < 1e-12);
2880            assert!((e - 100.0).abs() < 1e-12);
2881        } else {
2882            panic!("got {v:?}");
2883        }
2884    }
2885
2886    #[test]
2887    fn log_steps_3_decades() {
2888        let v = evaluate_spec("log_steps(1, 1000, 4)", &empty_kernel()).unwrap();
2889        // 1, 10, 100, 1000
2890        if let [Value::F64(a), Value::F64(b), Value::F64(c), Value::F64(d)] = v.as_slice() {
2891            assert!((a - 1.0).abs() < 1e-9);
2892            assert!((b - 10.0).abs() < 1e-9);
2893            assert!((c - 100.0).abs() < 1e-9);
2894            assert!((d - 1000.0).abs() < 1e-9);
2895        } else {
2896            panic!("got {v:?}");
2897        }
2898    }
2899
2900    #[test]
2901    fn log_steps_rejects_non_positive_bounds() {
2902        let err = evaluate_spec("log_steps(0, 100, 5)", &empty_kernel())
2903            .unwrap_err()
2904            .to_string();
2905        assert!(err.contains("must be positive"), "{err}");
2906    }
2907
2908    // ── SRD-18c Layer 5 / SRD-18e Push 9: set operators ──
2909
2910    #[test]
2911    fn concat_two_ranges() {
2912        let v = evaluate_spec("concat(1..4, 10..13)", &empty_kernel()).unwrap();
2913        assert_eq!(
2914            v,
2915            vec![
2916                Value::U64(1),
2917                Value::U64(2),
2918                Value::U64(3),
2919                Value::U64(10),
2920                Value::U64(11),
2921                Value::U64(12),
2922            ]
2923        );
2924    }
2925
2926    #[test]
2927    fn unique_dedupes_first_occurrence() {
2928        let v = evaluate_spec("unique(1..4, 3..6)", &empty_kernel()).unwrap();
2929        // 1,2,3 (from first) + 4,5 (from second; 3 already present)
2930        assert_eq!(
2931            v,
2932            vec![
2933                Value::U64(1),
2934                Value::U64(2),
2935                Value::U64(3),
2936                Value::U64(4),
2937                Value::U64(5),
2938            ]
2939        );
2940    }
2941
2942    #[test]
2943    fn intersect_keeps_only_common_values() {
2944        let v = evaluate_spec("intersect(1..10, 5..15)", &empty_kernel()).unwrap();
2945        assert_eq!(
2946            v,
2947            vec![
2948                Value::U64(5),
2949                Value::U64(6),
2950                Value::U64(7),
2951                Value::U64(8),
2952                Value::U64(9),
2953            ]
2954        );
2955    }
2956
2957    #[test]
2958    fn subtract_drops_values_in_b() {
2959        let v = evaluate_spec("subtract(1..6, 3..5)", &empty_kernel()).unwrap();
2960        // 1..6 = [1,2,3,4,5], minus [3,4] = [1, 2, 5]
2961        assert_eq!(v, vec![Value::U64(1), Value::U64(2), Value::U64(5)]);
2962    }
2963
2964    #[test]
2965    fn interleave_round_robin_two_lists() {
2966        let v = evaluate_spec("interleave(1..4, 10..13)", &empty_kernel()).unwrap();
2967        assert_eq!(
2968            v,
2969            vec![
2970                Value::U64(1),
2971                Value::U64(10),
2972                Value::U64(2),
2973                Value::U64(11),
2974                Value::U64(3),
2975                Value::U64(12),
2976            ]
2977        );
2978    }
2979
2980    #[test]
2981    fn cycle_repeats_n_times() {
2982        let v = evaluate_spec("cycle(1..3, 3)", &empty_kernel()).unwrap();
2983        assert_eq!(
2984            v,
2985            vec![
2986                Value::U64(1),
2987                Value::U64(2),
2988                Value::U64(1),
2989                Value::U64(2),
2990                Value::U64(1),
2991                Value::U64(2),
2992            ]
2993        );
2994    }
2995
2996    #[test]
2997    fn reverse_inverts_list() {
2998        let v = evaluate_spec("reverse(1..5)", &empty_kernel()).unwrap();
2999        assert_eq!(
3000            v,
3001            vec![Value::U64(4), Value::U64(3), Value::U64(2), Value::U64(1),]
3002        );
3003    }
3004
3005    #[test]
3006    fn take_n_takes_prefix() {
3007        let v = evaluate_spec("take(1..10, 3)", &empty_kernel()).unwrap();
3008        assert_eq!(v, vec![Value::U64(1), Value::U64(2), Value::U64(3)]);
3009    }
3010
3011    #[test]
3012    fn skip_n_drops_prefix() {
3013        let v = evaluate_spec("skip(1..6, 2)", &empty_kernel()).unwrap();
3014        assert_eq!(v, vec![Value::U64(3), Value::U64(4), Value::U64(5)]);
3015    }
3016
3017    #[test]
3018    fn unique_composes_with_pow2_and_range() {
3019        let v = evaluate_spec("unique(pow2(8), 1..1000..100)", &empty_kernel()).unwrap();
3020        // pow2(8) = 1, 2, 4, 8, 16, 32, 64, 128
3021        // 1..1000..100 = 1, 101, 201, 301, 401, 501, 601, 701, 801, 901
3022        // dedupe: 1, 2, 4, 8, 16, 32, 64, 128, 101, 201, 301, 401, 501, 601, 701, 801, 901
3023        assert_eq!(v.len(), 17);
3024        assert_eq!(v[0], Value::U64(1));
3025        assert_eq!(v[7], Value::U64(128));
3026        assert_eq!(v[8], Value::U64(101));
3027    }
3028
3029    // ── SRD-18c §"Sequencer-style expansions" / Push 8 ──
3030
3031    #[test]
3032    fn bucket_round_robin_3_1_2() {
3033        // Two-arg form: items list + ratios list.
3034        let v = evaluate_spec(
3035            "bucket(concat('ann', 'scan', 'fetch'), concat(3, 1, 2))",
3036            &empty_kernel(),
3037        )
3038        .unwrap();
3039        // Wait — concat doesn't make sense with these args (mixed types).
3040        // Use the literal form via the Polydat list parser.
3041        let _ = v;
3042    }
3043
3044    #[test]
3045    fn bucket_ratio_prefix_shorthand_round_robin() {
3046        let v = evaluate_spec("bucket(\"3:ann, 1:scan, 2:fetch\")", &empty_kernel()).unwrap();
3047        // Bucket sequencer round-robins; each "tick" pulls
3048        // one from each remaining bucket. Total = 6.
3049        assert_eq!(v.len(), 6);
3050        let strs: Vec<&str> = v
3051            .iter()
3052            .filter_map(|v| match v {
3053                Value::Str(s) => Some(&**s),
3054                _ => None,
3055            })
3056            .collect();
3057        // First tick: ann, scan, fetch (one from each).
3058        // Then ann (3 left), fetch (2 left). Next: ann, fetch.
3059        // Then ann. Total: ann*3, scan*1, fetch*2.
3060        let counts = strs.iter().fold(
3061            std::collections::HashMap::<&str, usize>::new(),
3062            |mut m, s| {
3063                *m.entry(s).or_insert(0) += 1;
3064                m
3065            },
3066        );
3067        assert_eq!(counts.get("ann"), Some(&3));
3068        assert_eq!(counts.get("scan"), Some(&1));
3069        assert_eq!(counts.get("fetch"), Some(&2));
3070    }
3071
3072    #[test]
3073    fn concat_seq_emits_contiguous_runs() {
3074        let v = evaluate_spec(
3075            "concat_seq(\"2:warmup, 3:bench, 1:cooldown\")",
3076            &empty_kernel(),
3077        )
3078        .unwrap();
3079        let strs: Vec<String> = v
3080            .iter()
3081            .filter_map(|v| match v {
3082                Value::Str(s) => Some(s.to_string()),
3083                _ => None,
3084            })
3085            .collect();
3086        assert_eq!(
3087            strs,
3088            vec!["warmup", "warmup", "bench", "bench", "bench", "cooldown",]
3089        );
3090    }
3091
3092    #[test]
3093    fn interval_seq_evenly_spreads_higher_ratio() {
3094        let v = evaluate_spec("interval_seq(\"3:read, 1:write\")", &empty_kernel()).unwrap();
3095        // Total length 4. write should appear once,
3096        // somewhere in the middle (not bunched at edges).
3097        let strs: Vec<String> = v
3098            .iter()
3099            .filter_map(|v| match v {
3100                Value::Str(s) => Some(s.to_string()),
3101                _ => None,
3102            })
3103            .collect();
3104        assert_eq!(strs.len(), 4);
3105        let writes: Vec<usize> = strs
3106            .iter()
3107            .enumerate()
3108            .filter(|(_, s)| *s == "write")
3109            .map(|(i, _)| i)
3110            .collect();
3111        assert_eq!(writes.len(), 1, "expected exactly one write: {strs:?}");
3112    }
3113
3114    #[test]
3115    fn parse_func_call_recognises_simple_call() {
3116        let (n, a) = parse_func_call("fib(8)").unwrap();
3117        assert_eq!(n, "fib");
3118        assert_eq!(a, "8");
3119    }
3120
3121    #[test]
3122    fn parse_func_call_rejects_non_calls() {
3123        assert!(parse_func_call("1..10").is_none());
3124        assert!(parse_func_call("foo + bar").is_none());
3125        assert!(parse_func_call("f(a) + g(b)").is_none()); // mid-text close
3126    }
3127
3128    #[test]
3129    fn split_args_top_level_skips_inner_commas() {
3130        let args = split_args_top_level("a, f(b, c), \"x, y\", 3");
3131        assert_eq!(args, vec!["a", "f(b, c)", "\"x, y\"", "3"]);
3132    }
3133}