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