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