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