Skip to main content

polydat_core/iteration/
cursor_partition.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Cursor partition specs (cursor_partitions.md).
5//!
6//! Value types and a small spec language following the token
7//! grammar `chunking [in window] [order]`:
8//!
9//! - [`PartitionSpec`] — the parsed-but-unresolved form of a partition
10//!   spec string (an `over "..."` clause, a `partitions(spec, n)`
11//!   argument, or a host's cursor parameter): a [`Chunking`] (Form 1 single
12//!   sub-range, Form 2 delta list, or Form 3 pre-baked recipe,
13//!   as typed [`Bound`]s), an optional `in start..end` window,
14//!   and a [`PartitionOrder`].
15//! - [`Partition`] — a single resolved partition with concrete
16//!   absolute ordinals, computed by [`resolve`] from a
17//!   `PartitionSpec` against a known base extent.
18//!
19//! The parser, the resolution math, and the host-side `over` narrowing
20//! helpers (`resolve_over`, `cursor_over_partitions[_on]`, `narrow_cursor`)
21//! live here; the compiler's handling of the `over` clause is in
22//! `dsl::compile` and the cursor source factories are in
23//! `iteration::source`. The Polydat `Value`
24//! integration rides on the existing [`Value::Ext`] /
25//! [`ReflectedValue`] mechanism — see the impls below.
26
27use std::fmt;
28use std::sync::Arc;
29
30use crate::ast::{ReflectedValue, Value};
31
32/// One numeric boundary or list token inside a partition spec.
33/// The forms are distinguished syntactically at parse time:
34/// - Trailing `%` → [`Bound::Pct`]
35/// - Decimal in `[0.0, 1.0]` → [`Bound::Frac`]
36/// - Bare integer → [`Bound::Ord`]
37/// - Literal `*` (or `*%`) → [`Bound::Star`]
38/// - Literal `...` → [`Bound::Fill`]
39/// - `*/N` (bare integer N) → [`Bound::StarSplit`]
40///
41/// [`Bound::Pct`] and [`Bound::Frac`] are equivalent at resolve
42/// time (the latter is just the former divided by 100); both
43/// require a base extent. [`Bound::Ord`] is already absolute.
44///
45/// The tail tokens (`Star`, `Fill`, `StarSplit`, `StarShaped`)
46/// are valid only inside a Form 2 delta list and at most one per
47/// list. `Star` absorbs the remainder as a single partition;
48/// `Fill` repeats the preceding delta until the extent is used
49/// up; `StarSplit(n)` divides the remainder into `n` equal
50/// partitions; `StarShaped(weights)` divides it by recipe
51/// weights. `Fill`, `StarSplit`, and `StarShaped` must be the
52/// final entry; `Star` may sit anywhere in the list. `Gap`
53/// wraps a sized bound and consumes extent without emitting a
54/// partition.
55#[derive(Debug, Clone, PartialEq)]
56pub enum Bound {
57    /// Percentage of the cursor's base extent, `[0.0, 100.0]`.
58    Pct(f64),
59    /// Fraction of the cursor's base extent, `[0.0, 1.0]`.
60    /// Equivalent to `Pct(value * 100)`.
61    Frac(f64),
62    /// Absolute cursor ordinal (already in ordinal space).
63    Ord(u64),
64    /// Remainder marker — absorbs whatever's needed for the
65    /// containing delta list to span the cursor's full extent,
66    /// as one partition.
67    Star,
68    /// Fill marker (`...`) — repeats the preceding delta until
69    /// the extent is used up. A final chunk smaller than the
70    /// repeated delta is emitted truncated, never dropped.
71    Fill,
72    /// Remainder split (`*/N`) — divides whatever's left after
73    /// the other deltas into `N` partitions whose sizes differ
74    /// by at most one ordinal.
75    StarSplit(u64),
76    /// Recipe-shaped remainder (`*/fib:5`, `*/ratios:1,3`, …) —
77    /// divides whatever's left by the recipe's normalised
78    /// weights (stored summing to 100).
79    StarShaped(Vec<f64>),
80    /// Gap (`~10%`, `~1000`) — consumes the wrapped sized
81    /// bound's extent without emitting a partition. The walk
82    /// stays contiguous; the *emitted* partition set skips this
83    /// range. Parse guarantees the inner bound is sized
84    /// (`Pct` / `Frac` / `Ord`).
85    Gap(Box<Bound>),
86}
87
88impl Bound {
89    /// Resolve this bound to an absolute ordinal in
90    /// `[base_start, base_end]` against a known extent. Returns
91    /// `None` for the tail tokens and gaps — their resolution
92    /// depends on list context and is the caller's
93    /// responsibility.
94    pub fn resolve_against(&self, base_start: u64, base_end: u64) -> Option<u64> {
95        let extent = base_end.saturating_sub(base_start);
96        match self {
97            Bound::Pct(p) => Some(base_start + ((p / 100.0) * extent as f64).round() as u64),
98            Bound::Frac(f) => Some(base_start + (f * extent as f64).round() as u64),
99            Bound::Ord(o) => Some(base_start.saturating_add(*o).min(base_end)),
100            Bound::Star
101            | Bound::Fill
102            | Bound::StarSplit(_)
103            | Bound::StarShaped(_)
104            | Bound::Gap(_) => None,
105        }
106    }
107
108    /// True for the tail tokens that consume the unallocated
109    /// remainder of the extent rather than naming a fixed size.
110    pub fn is_tail(&self) -> bool {
111        matches!(
112            self,
113            Bound::Star | Bound::Fill | Bound::StarSplit(_) | Bound::StarShaped(_)
114        )
115    }
116
117    /// True for the sized forms (`Pct` / `Frac` / `Ord`) that
118    /// name a fixed amount of extent.
119    pub fn is_sized(&self) -> bool {
120        matches!(self, Bound::Pct(_) | Bound::Frac(_) | Bound::Ord(_))
121    }
122}
123
124impl fmt::Display for Bound {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        match self {
127            Bound::Pct(p) => write!(f, "{p}%"),
128            Bound::Frac(v) => write!(f, "{v}"),
129            Bound::Ord(o) => write!(f, "{o}"),
130            Bound::Star => write!(f, "*"),
131            Bound::Fill => write!(f, "..."),
132            Bound::StarSplit(n) => write!(f, "*/{n}"),
133            Bound::StarShaped(w) => {
134                let ws: Vec<String> = w.iter().map(|x| format!("{x:.3}")).collect();
135                write!(f, "*/shaped:{}", ws.join(","))
136            }
137            Bound::Gap(inner) => write!(f, "~{inner}"),
138        }
139    }
140}
141
142/// Iteration order for a resolved partition list — the optional
143/// trailing keyword of a spec (`"fib:5 largest_first"`).
144///
145/// `unchanged` and `random` are a **common-subset vocabulary**
146/// shared conceptually with comprehension traversal orders
147/// (`polydat/docs/design/comprehension_forms.md`) — where a word exists in both places it means the
148/// same thing, while comprehensions additionally offer
149/// algorithm-specific strategies (sobol, halton, lhs, …) that
150/// do not apply to partition lists.
151///
152/// The size sorts are named for their axis: partition lists are
153/// positionally contiguous by construction, so an unqualified
154/// "ascending" would be ambiguous between ordinal position
155/// (always the generation order — an alias of `unchanged`) and
156/// size. `smallest_first` / `largest_first` key on
157/// **cardinality**, unambiguously (stable — ties keep
158/// generation order). The bare words `ascending` / `descending`
159/// are rejected at parse time with a diagnostic naming these.
160/// `Random` is a deterministic shuffle seeded from the spec
161/// text, so the same spec yields the same order on every run.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
163pub enum PartitionOrder {
164    /// Generation order (left-to-right as resolved). The default.
165    #[default]
166    Unchanged,
167    /// Smallest cardinality first (stable).
168    SmallestFirst,
169    /// Largest cardinality first (stable).
170    LargestFirst,
171    /// Deterministic shuffle, seeded from the spec text.
172    Random,
173}
174
175impl fmt::Display for PartitionOrder {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        let s = match self {
178            PartitionOrder::Unchanged => "unchanged",
179            PartitionOrder::SmallestFirst => "smallest_first",
180            PartitionOrder::LargestFirst => "largest_first",
181            PartitionOrder::Random => "random",
182        };
183        write!(f, "{s}")
184    }
185}
186
187/// The chunking part of a spec — the shape that carves a domain
188/// into partitions. Two shapes:
189///
190/// - [`Chunking::SingleRange`] — Form 1: an explicit
191///   `start..end` interval. Always exactly one partition at
192///   resolve time.
193/// - [`Chunking::DeltaList`] — Forms 2 and 3: an ordered list
194///   of per-partition delta sizes, walked left-to-right from
195///   the domain's start. Pre-baked recipes (`bin:5`, `fib:7`,
196///   etc.) parse into normalised percentage deltas.
197#[derive(Debug, Clone, PartialEq)]
198pub enum Chunking {
199    /// `start..end` form. Single partition spanning the named
200    /// boundary, regardless of either endpoint's `Bound` kind.
201    SingleRange {
202        /// Where the partition starts.
203        start: Bound,
204        /// Where it ends.
205        end: Bound,
206    },
207    /// Comma-separated delta list. Each entry is the delta
208    /// from the running start; a single tail token is allowed
209    /// per list and resolves against whatever's left after the
210    /// sized deltas are applied: [`Bound::Star`] takes it as
211    /// one partition, [`Bound::Fill`] repeats the preceding
212    /// delta until the extent is used up, [`Bound::StarSplit`]
213    /// divides it into `n` near-equal partitions,
214    /// [`Bound::StarShaped`] divides it by recipe weights.
215    /// [`Bound::Gap`] entries consume extent without emitting.
216    /// Deltas summing to less than the extent (without a tail
217    /// token) drop the trailing gap; summing to more is a
218    /// resolve-time error.
219    DeltaList {
220        /// The deltas, in order.
221        deltas: Vec<Bound>,
222    },
223}
224
225/// A parsed partition spec:
226///
227/// ```text
228/// spec   := chunking [ "in" window ] [ order ]
229/// window := Form 1 range (e.g. `25%..75%`)
230/// order  := unchanged | smallest_first | largest_first | random
231/// ```
232///
233/// The window scopes the chunking: the chunking resolves
234/// against the window's ordinal range instead of the full
235/// extent, so percentages inside the chunking are relative to
236/// the **window**. Without a window, the chunking spans the
237/// whole extent. The order keyword reorders the resolved list
238/// for iteration; partition `idx` keeps identifying the
239/// generation position.
240#[derive(Debug, Clone, PartialEq)]
241pub struct PartitionSpec {
242    /// The shape that carves the (windowed) domain.
243    pub chunking: Chunking,
244    /// Optional `in start..end` window the chunking applies to.
245    pub window: Option<(Bound, Bound)>,
246    /// Iteration order of the resolved list.
247    pub order: PartitionOrder,
248}
249
250impl PartitionSpec {
251    /// A windowless, unordered Form 1 spec.
252    pub fn single_range(start: Bound, end: Bound) -> Self {
253        Self {
254            chunking: Chunking::SingleRange { start, end },
255            window: None,
256            order: PartitionOrder::Unchanged,
257        }
258    }
259
260    /// A windowless, unordered Form 2/3 spec.
261    pub fn delta_list(deltas: Vec<Bound>) -> Self {
262        Self {
263            chunking: Chunking::DeltaList { deltas },
264            window: None,
265            order: PartitionOrder::Unchanged,
266        }
267    }
268}
269
270/// A single resolved partition: an absolute ordinal range with
271/// derived percentage and index metadata.
272///
273/// `cardinality()` returns `end_ord - start_ord` — the number
274/// of ordinals the partition covers.
275#[derive(Debug, Clone, Copy, PartialEq)]
276pub struct Partition {
277    /// 0-based position in the resolved partition list
278    /// (generation order — stable under spec-level reordering).
279    pub idx: u64,
280    /// Total number of partitions in the list this one was
281    /// resolved as part of. `1` for a single-partition spec.
282    /// Carried per-partition so an iter-var or `q.cursor`
283    /// projection can answer `partition_count` (and the status
284    /// banner can render `i/n`) without the originating list.
285    pub count: u64,
286    /// Absolute ordinal at partition start (inclusive).
287    pub start_ord: u64,
288    /// Absolute ordinal at partition end (exclusive).
289    pub end_ord: u64,
290    /// Start as a percentage of the base extent, `[0.0, 100.0)`.
291    pub start_pct: f64,
292    /// End as a percentage of the base extent, `(0.0, 100.0]`.
293    pub end_pct: f64,
294    /// The base extent the partition was resolved against.
295    /// Stored so consumers can recompute pcts or compare
296    /// partitions resolved against different extents.
297    pub base_extent: u64,
298}
299
300impl Partition {
301    /// Number of ordinals in the partition: `end_ord - start_ord`.
302    #[inline]
303    pub fn cardinality(&self) -> u64 {
304        self.end_ord - self.start_ord
305    }
306}
307
308// =========================================================================
309// Parser
310// =========================================================================
311
312/// Parse a partition spec string into a [`PartitionSpec`].
313///
314/// Accepts all three forms documented in cursor_partitions.md §3:
315///
316/// - Form 1 — single sub-range: `0..53%`, `[0..53%)`, `100..1000`,
317///   `0.05..0.5`, `100..50%`. Bracket placement and closure
318///   markers (`[ ] ( )`) are tolerated but advisory; the closure
319///   is always `[start, end)`.
320/// - Form 2 — delta list: `2%,10%,*%`, `0.02,0.10,*`,
321///   `1000,5000,*`, `1000,10%,*`, `20%,30%`. Tail tokens:
322///   `*` (remainder as one partition), `...` (repeat the
323///   preceding delta until the extent is used up, e.g.
324///   `90%,1%,...`), `*/N` (remainder divided into N equal
325///   partitions, e.g. `90%,*/10`), `*/recipe:args` (remainder
326///   shaped by recipe weights, e.g. `90%,*/fib:5`). Entry
327///   modifiers: `<delta>xN` finite repetition (`1%x5` = five 1%
328///   chunks), `~<delta>` gap (`~10%` consumes 10% of the extent
329///   without emitting a partition).
330/// - Form 3 — pre-baked recipe: `linear:N`, `ratios:a,b,c,…`,
331///   `mul:R`, `mul:S,R`, `bin:N`, `fib:N`, `ln:N`, `geom:N,R`,
332///   `zipf:s,N`, `pareto:alpha,N`, `front_heavy:N`,
333///   `back_heavy:N`.
334///
335/// The whole spec follows the token grammar
336/// `chunking [in window] [order]` — a whitespace-delimited `in`
337/// scopes the chunking to a Form 1 window (`linear:5 in
338/// 25%..75%`), and a trailing order keyword (`unchanged` /
339/// `smallest_first` / `largest_first` / `random`) reorders the
340/// resolved list for iteration (`fib:5 largest_first`).
341///
342/// Whitespace is otherwise ignored. Bracket characters (`[`,
343/// `]`, `(`, `)`) are stripped unconditionally — they're
344/// advisory closure markers in the grammar (everything's always
345/// `[start, end)` at resolve time), so any placement parses the
346/// same way.
347pub fn parse(input: &str) -> Result<PartitionSpec, String> {
348    // Token phase: `chunking [in window] [order]`. Whitespace
349    // splits tokens; within the chunking / window parts it
350    // carries no meaning and the parts are re-joined.
351    let mut tokens: Vec<&str> = input.split_whitespace().collect();
352    if tokens.is_empty() {
353        return Err(format!("empty spec: `{input}`"));
354    }
355    // Order suffix: a trailing bare-word token (alphabetic plus
356    // `_`; a spec body never ends in a bare word — recipes
357    // carry `:`).
358    let mut order = PartitionOrder::Unchanged;
359    if tokens.len() >= 2 {
360        let last = *tokens.last().unwrap();
361        if !last.is_empty()
362            && last.chars().all(|c| c.is_ascii_alphabetic() || c == '_')
363            && last != "in"
364        {
365            order = match last {
366                "unchanged" => PartitionOrder::Unchanged,
367                "smallest_first" => PartitionOrder::SmallestFirst,
368                "largest_first" => PartitionOrder::LargestFirst,
369                "random" => PartitionOrder::Random,
370                // Size sorts are named for their axis: a bare
371                // direction word is ambiguous between ordinal
372                // position (always the generation order) and
373                // size, so teach the unambiguous spelling.
374                "ascending" => {
375                    return Err("`ascending`: partition order sorts key on partition SIZE, \
376                         not ordinal position (position order is always the \
377                         generation order — that's `unchanged`). Spell it \
378                         `smallest_first`"
379                        .into());
380                }
381                "descending" => {
382                    return Err(
383                        "`descending`: partition order sorts key on partition SIZE, \
384                         not ordinal position (position order is always the \
385                         generation order — that's `unchanged`). Spell it \
386                         `largest_first`"
387                            .into(),
388                    );
389                }
390                other => {
391                    return Err(format!(
392                        "unknown order `{other}` — supported: unchanged, \
393                         smallest_first, largest_first, random"
394                    ));
395                }
396            };
397            tokens.pop();
398        }
399    }
400    // Window clause: a standalone `in` token splits chunking
401    // from window.
402    let in_positions: Vec<usize> = tokens
403        .iter()
404        .enumerate()
405        .filter_map(|(i, t)| (*t == "in").then_some(i))
406        .collect();
407    let (chunk_tokens, window_tokens): (&[&str], Option<&[&str]>) = match in_positions.as_slice() {
408        [] => (&tokens[..], None),
409        [i] => {
410            if *i == 0 {
411                return Err(format!("`in` without a chunking spec before it: `{input}`"));
412            }
413            if *i == tokens.len() - 1 {
414                return Err(format!("`in` without a window range after it: `{input}`"));
415            }
416            (&tokens[..*i], Some(&tokens[*i + 1..]))
417        }
418        _ => {
419            return Err(format!(
420                "at most one `in <window>` clause is allowed: `{input}`"
421            ));
422        }
423    };
424    let window = match window_tokens {
425        None => None,
426        Some(wt) => Some(parse_window(&clean_part(wt), input)?),
427    };
428    let chunking = parse_chunking(&clean_part(chunk_tokens), input)?;
429    Ok(PartitionSpec {
430        chunking,
431        window,
432        order,
433    })
434}
435
436/// Re-join part tokens and strip the advisory bracket markers.
437fn clean_part(tokens: &[&str]) -> String {
438    tokens
439        .concat()
440        .chars()
441        .filter(|c| !matches!(c, '[' | ']' | '(' | ')'))
442        .collect()
443}
444
445/// Parse the window of an `in` clause: a Form 1 range with
446/// sized endpoints.
447fn parse_window(cleaned: &str, input: &str) -> Result<(Bound, Bound), String> {
448    let Some((lhs, rhs)) = split_range(cleaned) else {
449        return Err(format!(
450            "the window after `in` must be a `start..end` range; got `{cleaned}` in `{input}`"
451        ));
452    };
453    let start = parse_bound(lhs)?;
454    let end = parse_bound(rhs)?;
455    if !start.is_sized() || !end.is_sized() {
456        return Err(format!(
457            "window endpoints must be sized values (percentage, fraction, or \
458             ordinal); got `{cleaned}` in `{input}`"
459        ));
460    }
461    Ok((start, end))
462}
463
464/// Parse the chunking part of a spec (everything except the
465/// `in` window and order suffix).
466fn parse_chunking(cleaned: &str, input: &str) -> Result<Chunking, String> {
467    if cleaned.is_empty() {
468        return Err(format!("empty spec: `{input}`"));
469    }
470    // Form 3: pre-baked recipe — `name:args` with an alphabetic
471    // name.
472    if let Some((name, args)) = split_recipe(cleaned) {
473        let deltas = normalise_to_pct(&expand_recipe_weights(name, args)?)?;
474        return Ok(Chunking::DeltaList { deltas });
475    }
476    // A lone fill token has nothing to repeat. Caught before the
477    // Form 1 check because `...` contains the `..` range marker.
478    if cleaned == "..." {
479        return Err(
480            "the fill token `...` repeats the preceding delta until the extent \
481             is used up; it needs at least one delta before it (e.g. `1%,...`)"
482                .into(),
483        );
484    }
485    // The star tail (`*/...`) may carry a recipe whose args
486    // contain commas, so it is split off before the comma walk.
487    // Tail tokens must be last, which is what makes this split
488    // unambiguous.
489    if cleaned.starts_with("*/") || cleaned.contains(",*/") {
490        return parse_delta_list(cleaned, input);
491    }
492    // Form 2: delta list — any comma makes it a list. Checked
493    // before Form 1 because a `...` fill entry contains the `..`
494    // range marker.
495    if cleaned.contains(',') {
496        return parse_delta_list(cleaned, input);
497    }
498    // Form 1: single sub-range — contains `..`.
499    if let Some((lhs, rhs)) = split_range(cleaned) {
500        let start = parse_bound(lhs)?;
501        let end = parse_bound(rhs)?;
502        // Form 1 doesn't allow the list tail tokens.
503        if !start.is_sized() || !end.is_sized() {
504            return Err(format!(
505                "`*`, `...`, `~`, and `*/N` are only valid inside a comma-separated \
506                 delta list, not a `..` range; got `{input}`"
507            ));
508        }
509        return Ok(Chunking::SingleRange { start, end });
510    }
511    // Single-entry delta list (`50%`, `*`, `1%x5`, ...).
512    parse_delta_list(cleaned, input)
513}
514
515/// Parse a comma-separated Form 2 delta list and validate its
516/// grammar: at most one tail token (`*` / `...` / `*/N` /
517/// `*/recipe`) per list; `...`, `*/N`, and `*/recipe` must be
518/// the final entry (`*` may sit anywhere); `...` needs a
519/// preceding sized, non-gap delta; at least one entry must emit
520/// a partition.
521fn parse_delta_list(cleaned: &str, input: &str) -> Result<Chunking, String> {
522    // Split off a star tail first — its recipe args may contain
523    // commas (`*/ratios:1,3`).
524    let (head, star_tail) = if let Some(rest) = cleaned.strip_prefix("*/") {
525        ("", Some(rest))
526    } else if let Some(pos) = cleaned.find(",*/") {
527        (&cleaned[..pos], Some(&cleaned[pos + 3..]))
528    } else {
529        (cleaned, None)
530    };
531    let mut deltas: Vec<Bound> = Vec::new();
532    if !head.is_empty() {
533        for entry in head.split(',') {
534            if entry.is_empty() {
535                return Err(format!("empty entry in delta list: `{input}`"));
536            }
537            deltas.extend(parse_delta_entry(entry)?);
538        }
539    } else if star_tail.is_none() {
540        return Err(format!("empty spec: `{input}`"));
541    }
542    if let Some(tail) = star_tail {
543        deltas.push(parse_star_tail(tail)?);
544    }
545    let tail_count = deltas.iter().filter(|b| b.is_tail()).count();
546    if tail_count > 1 {
547        return Err(format!(
548            "at most one remainder token (`*`, `...`, `*/N`, or `*/recipe`) is \
549             allowed in a delta list; got {tail_count} in `{input}`"
550        ));
551    }
552    if let Some(pos) = deltas
553        .iter()
554        .position(|b| matches!(b, Bound::Fill | Bound::StarSplit(_) | Bound::StarShaped(_)))
555    {
556        if pos != deltas.len() - 1 {
557            return Err(format!(
558                "`{}` consumes the rest of the extent and must be the last entry \
559                 in the delta list; got `{input}`",
560                deltas[pos]
561            ));
562        }
563        if matches!(deltas[pos], Bound::Fill) {
564            if pos == 0 {
565                return Err(
566                    "the fill token `...` repeats the preceding delta until the extent \
567                     is used up; it needs at least one delta before it (e.g. `1%,...`)"
568                        .into(),
569                );
570            }
571            if matches!(deltas[pos - 1], Bound::Gap(_)) {
572                return Err(format!(
573                    "`...` after a gap would emit nothing — the fill token repeats \
574                     the immediately preceding delta. Put a sized delta before `...`, \
575                     in `{input}`"
576                ));
577            }
578        }
579    }
580    // A spec that never emits is a mistake, not an empty sweep.
581    if !deltas.iter().any(|b| b.is_sized() || b.is_tail()) {
582        return Err(format!(
583            "spec emits no partitions — every entry is a gap: `{input}`"
584        ));
585    }
586    Ok(Chunking::DeltaList { deltas })
587}
588
589/// Parse one head entry of a delta list: a sized bound, `*`,
590/// `...`, a gap (`~<sized>`), or a finite repetition
591/// (`<sized>xN`).
592fn parse_delta_entry(raw: &str) -> Result<Vec<Bound>, String> {
593    // Gap prefix: `~<sized>`.
594    if let Some(rest) = raw.strip_prefix('~') {
595        if let Some((_, rep)) = rest.split_once('x')
596            && !rep.is_empty()
597            && rep.chars().all(|c| c.is_ascii_digit())
598        {
599            return Err(format!(
600                "`~{rest}`: repetition does not apply to gaps — size the gap \
601                     directly (adjacent gaps are one gap)"
602            ));
603        }
604        let inner = parse_bound(rest)?;
605        if !inner.is_sized() {
606            return Err(format!(
607                "`~{rest}`: a gap requires a sized value (percentage, fraction, or \
608                 ordinal). To ignore the trailing remainder, just end the list \
609                 without a tail token — under-summing lists drop the gap"
610            ));
611        }
612        return Ok(vec![Bound::Gap(Box::new(inner))]);
613    }
614    // Finite repetition: `<sized>xN`.
615    if let Some((lhs, rhs)) = raw.split_once('x')
616        && !lhs.is_empty()
617        && !rhs.is_empty()
618        && rhs.chars().all(|c| c.is_ascii_digit())
619    {
620        let n: u64 = rhs
621            .parse()
622            .map_err(|_| format!("invalid repetition count in `{raw}`"))?;
623        if n == 0 {
624            return Err(format!("`{raw}`: the repetition count must be >= 1"));
625        }
626        let b = parse_bound(lhs)?;
627        if !b.is_sized() {
628            return Err(format!(
629                "`{raw}`: repetition applies to sized deltas (percentage, \
630                     fraction, or ordinal) only"
631            ));
632        }
633        // Reserved fallibly, which also proves `n` fits a `usize`.
634        let mut out = crate::derive_support::try_buffer_for(n, &format!("`{raw}`"))?;
635        out.resize(n as usize, b);
636        return Ok(out);
637    }
638    Ok(vec![parse_bound(raw)?])
639}
640
641/// Parse the divisor of a star tail (the text after `*/`):
642/// a bare integer count (`*/10`) or a recipe (`*/fib:5`).
643fn parse_star_tail(divisor: &str) -> Result<Bound, String> {
644    // A comma in a non-recipe divisor means entries follow the
645    // star tail — recipes own their comma-separated args, a bare
646    // count doesn't.
647    if !divisor.contains(':') && divisor.contains(',') {
648        let count = divisor.split(',').next().unwrap_or(divisor);
649        return Err(format!(
650            "`*/{count}` consumes the rest of the extent and must be the last \
651             entry in the delta list; got trailing entries after it"
652        ));
653    }
654    if let Some((name, args)) = split_recipe(divisor) {
655        if name == "linear" {
656            return Err(format!(
657                "`*/linear:{args}`: spell an equal-count remainder split as \
658                 `*/{args}` — `*/N` is the canonical form"
659            ));
660        }
661        let weights = normalise_weights(&expand_recipe_weights(name, args)?)?;
662        return Ok(Bound::StarShaped(weights));
663    }
664    if divisor.contains('%') || divisor.contains('.') {
665        return Err(format!(
666            "`*/{divisor}`: the divisor after `*/` is a chunk count and must be a \
667             bare integer (e.g. `*/10` = remainder in 10 equal chunks). For \
668             fixed-size chunks repeated until the extent is used up, spell the \
669             size as a delta followed by the fill token: `{divisor},...`"
670        ));
671    }
672    let n: u64 = divisor.parse().map_err(|_| {
673        format!("invalid remainder split `*/{divisor}`: expected `*/N` with integer N >= 1, or `*/recipe:args`")
674    })?;
675    if n == 0 {
676        return Err("`*/0`: the remainder split count must be >= 1".into());
677    }
678    Ok(Bound::StarSplit(n))
679}
680
681/// If `s` matches `<name>:<args>`, return (name, args). Recipe
682/// names are alphabetic-only (plus `_`) to avoid colliding with
683/// any number form.
684fn split_recipe(s: &str) -> Option<(&str, &str)> {
685    let colon = s.find(':')?;
686    let name = &s[..colon];
687    if name.is_empty() {
688        return None;
689    }
690    if !name.chars().all(|c| c.is_ascii_alphabetic() || c == '_') {
691        return None;
692    }
693    Some((name, &s[colon + 1..]))
694}
695
696/// Find a top-level `..` separator (the Form 1 range marker).
697/// Returns `None` if the input is a single value (no `..`).
698fn split_range(s: &str) -> Option<(&str, &str)> {
699    s.find("..").map(|idx| (&s[..idx], &s[idx + 2..]))
700}
701
702/// Parse a single numeric bound. The form is unambiguous from
703/// the literal's shape; see [`Bound`] for the form-to-variant
704/// mapping.
705fn parse_bound(raw: &str) -> Result<Bound, String> {
706    let s = raw.trim();
707    if s.is_empty() {
708        return Err("empty bound".into());
709    }
710    // Fill token: `...` — repeat the preceding delta.
711    if s == "..." {
712        return Ok(Bound::Fill);
713    }
714    // Star tails (`*/N`, `*/recipe`) are parsed by
715    // [`parse_star_tail`] — the delta-list walk splits them off
716    // before reaching here, so a `*/` reaching this point is a
717    // misplacement (e.g. a Form 1 endpoint) and falls through to
718    // the number-parse error below.
719    // Remainder token: `*` or `*%` (the `%` is decorative).
720    if s == "*" || s == "*%" {
721        return Ok(Bound::Star);
722    }
723    // Percentage form: trailing `%`.
724    if let Some(num) = s.strip_suffix('%') {
725        let value: f64 = num
726            .trim()
727            .parse()
728            .map_err(|_| format!("invalid percentage `{raw}`: expected a number before `%`"))?;
729        if !(0.0..=100.0).contains(&value) {
730            return Err(format!(
731                "percentage `{raw}` out of range — must be in [0%, 100%]"
732            ));
733        }
734        return Ok(Bound::Pct(value));
735    }
736    // Decimal-with-dot: fraction form.
737    if s.contains('.') {
738        let value: f64 = s.parse().map_err(|_| format!("invalid decimal `{raw}`"))?;
739        if !(0.0..=1.0).contains(&value) {
740            return Err(format!(
741                "decimal `{raw}` is ambiguous — fractions must be in [0.0, 1.0]; \
742                 did you mean `{}%` (percentage), `0.0{}` (fraction), or `{}` (literal ordinal)?",
743                value,
744                raw.replace('.', ""),
745                raw.replace('.', ""),
746            ));
747        }
748        return Ok(Bound::Frac(value));
749    }
750    // Bare integer: literal ordinal.
751    let value: u64 = s
752        .parse()
753        .map_err(|_| format!("invalid number `{raw}`: expected an integer ordinal, decimal fraction (0.x), or `N%` percentage"))?;
754    Ok(Bound::Ord(value))
755}
756
757// =========================================================================
758// Pre-baked recipes
759// =========================================================================
760
761/// Dispatch a recipe name + arg string to its raw weight list.
762/// Callers normalise: [`normalise_to_pct`] for a whole-spec
763/// recipe (Form 3), [`normalise_weights`] for a star tail
764/// (`*/recipe`).
765fn expand_recipe_weights(name: &str, args: &str) -> Result<Vec<f64>, String> {
766    let parts: Vec<&str> = args.split(',').map(|s| s.trim()).collect();
767    let weights = match name {
768        "linear" => recipe_linear(&parts)?,
769        "ratios" => recipe_ratios(&parts)?,
770        "mul" => recipe_mul(&parts)?,
771        "bin" => recipe_bin(&parts)?,
772        "fib" => recipe_fib(&parts)?,
773        "ln" => recipe_ln(&parts)?,
774        "geom" => recipe_geom(&parts)?,
775        "zipf" => recipe_zipf(&parts)?,
776        "pareto" => recipe_pareto(&parts)?,
777        "front_heavy" => recipe_front_heavy(&parts)?,
778        "back_heavy" => recipe_back_heavy(&parts)?,
779        _ => {
780            return Err(format!(
781                "unknown recipe `{name}` — supported: linear, ratios, mul, bin, fib, ln, \
782                 geom, zipf, pareto, front_heavy, back_heavy"
783            ));
784        }
785    };
786    Ok(weights)
787}
788
789fn parse_u64_arg(arg: &str, ctx: &str) -> Result<u64, String> {
790    arg.parse()
791        .map_err(|_| format!("invalid integer arg `{arg}` for {ctx}"))
792}
793
794fn parse_f64_arg(arg: &str, ctx: &str) -> Result<f64, String> {
795    arg.parse()
796        .map_err(|_| format!("invalid number arg `{arg}` for {ctx}"))
797}
798
799/// `n` recipe weights, the `i`-th (from 1) computed by `weight`. The
800/// count is the recipe's argument, from the spec text, so it is
801/// reserved fallibly: `linear:99999999999999` is a compile error
802/// naming the recipe, not an allocator abort. (Collecting a `u64`
803/// range would reserve all `n` at once, since the range reports its
804/// exact length.)
805fn weights_for(n: u64, recipe: &str, weight: impl FnMut(u64) -> f64) -> Result<Vec<f64>, String> {
806    let mut out = crate::derive_support::try_buffer_for(n, recipe)?;
807    out.extend((1..=n).map(weight));
808    Ok(out)
809}
810
811fn recipe_linear(args: &[&str]) -> Result<Vec<f64>, String> {
812    if args.len() != 1 {
813        return Err(format!(
814            "linear:N expects exactly 1 argument (the partition count); got {}",
815            args.len()
816        ));
817    }
818    let n = parse_u64_arg(args[0], "linear")?;
819    if n == 0 {
820        return Err("linear:N requires N >= 1".into());
821    }
822    weights_for(n, "linear:N", |_| 1.0)
823}
824
825fn recipe_ratios(args: &[&str]) -> Result<Vec<f64>, String> {
826    if args.is_empty() {
827        return Err("ratios:a,b,c,... requires at least one weight".into());
828    }
829    args.iter().map(|a| parse_f64_arg(a, "ratios")).collect()
830}
831
832fn recipe_mul(args: &[&str]) -> Result<Vec<f64>, String> {
833    let (start, ratio) = match args.len() {
834        1 => (1.0, parse_f64_arg(args[0], "mul")?),
835        2 => (
836            parse_f64_arg(args[0], "mul")?,
837            parse_f64_arg(args[1], "mul")?,
838        ),
839        n => {
840            return Err(format!(
841                "mul:R or mul:S,R expects 1 or 2 arguments; got {n}"
842            ));
843        }
844    };
845    if start <= 0.0 {
846        return Err(format!("mul:S,R requires S > 0; got {start}"));
847    }
848    if ratio <= 0.0 {
849        return Err(format!("mul:R requires R > 0; got {ratio}"));
850    }
851    // Two termination rules, whichever fires first:
852    //  - decay case (R < 1): stop when current < start * 0.001 — the
853    //    new term contributes less than 0.1% of the leading partition.
854    //  - growth case (R >= 1): hard term cap. Without an explicit
855    //    count the natural choice is the term where the geometric
856    //    growth has covered ~3 orders of magnitude; that's about
857    //    log_R(1000) terms. Use `geom:N,R` instead when you want a
858    //    specific term count.
859    const HARD_CAP: usize = 64;
860    let mut weights = Vec::with_capacity(HARD_CAP);
861    let mut current = start;
862    for _ in 0..HARD_CAP {
863        if !current.is_finite() || current <= 0.0 {
864            break;
865        }
866        weights.push(current);
867        if ratio < 1.0 && current < start * 0.001 {
868            break;
869        }
870        current *= ratio;
871        if ratio >= 1.0 && current >= start * 1000.0 {
872            // Growth-case stop: include the next term so the
873            // last partition is the dominant one.
874            if current.is_finite() {
875                weights.push(current);
876            }
877            break;
878        }
879    }
880    if weights.is_empty() {
881        return Err(format!(
882            "mul:{start},{ratio} produced no terms — pick a larger start"
883        ));
884    }
885    Ok(weights)
886}
887
888fn recipe_bin(args: &[&str]) -> Result<Vec<f64>, String> {
889    if args.len() != 1 {
890        return Err(format!(
891            "bin:N expects exactly 1 argument (the term count); got {}",
892            args.len()
893        ));
894    }
895    let n = parse_u64_arg(args[0], "bin")?;
896    if n == 0 {
897        return Err("bin:N requires N >= 1".into());
898    }
899    // Coefficients of (1+x)^(N-1): C(N-1, k) for k = 0..N-1.
900    let degree = n - 1;
901    let mut coeffs = weights_for(n, "bin:N", |_| 1.0)?;
902    for k in 1..=degree {
903        coeffs[k as usize] = coeffs[(k - 1) as usize] * ((degree - k + 1) as f64) / (k as f64);
904    }
905    Ok(coeffs)
906}
907
908fn recipe_fib(args: &[&str]) -> Result<Vec<f64>, String> {
909    if args.len() != 1 {
910        return Err(format!(
911            "fib:N expects exactly 1 argument (the term count); got {}",
912            args.len()
913        ));
914    }
915    let n = parse_u64_arg(args[0], "fib")?;
916    if n == 0 {
917        return Err("fib:N requires N >= 1".into());
918    }
919    // Skip the redundant leading `1, 1` — use the distinct
920    // Fibonacci values starting at 1: 1, 2, 3, 5, 8, 13, ...
921    let mut weights = crate::derive_support::try_buffer_for(n, "fib:N")?;
922    let (mut a, mut b) = (1u64, 2u64);
923    for _ in 0..n {
924        weights.push(a as f64);
925        let next = a.saturating_add(b);
926        a = b;
927        b = next;
928    }
929    Ok(weights)
930}
931
932fn recipe_ln(args: &[&str]) -> Result<Vec<f64>, String> {
933    if args.len() != 1 {
934        return Err(format!(
935            "ln:N expects exactly 1 argument (the term count); got {}",
936            args.len()
937        ));
938    }
939    let n = parse_u64_arg(args[0], "ln")?;
940    if n == 0 {
941        return Err("ln:N requires N >= 1".into());
942    }
943    weights_for(n, "ln:N", |i| (1.0 + i as f64).ln())
944}
945
946fn recipe_geom(args: &[&str]) -> Result<Vec<f64>, String> {
947    if args.len() != 2 {
948        return Err(format!(
949            "geom:N,R expects exactly 2 arguments; got {}",
950            args.len()
951        ));
952    }
953    let n = parse_u64_arg(args[0], "geom")?;
954    let r = parse_f64_arg(args[1], "geom")?;
955    if n == 0 {
956        return Err("geom:N,R requires N >= 1".into());
957    }
958    if r <= 0.0 {
959        return Err(format!("geom:N,R requires R > 0; got {r}"));
960    }
961    let mut weights = crate::derive_support::try_buffer_for(n, "geom:N,R")?;
962    let mut current = 1.0;
963    for _ in 0..n {
964        weights.push(current);
965        current *= r;
966    }
967    Ok(weights)
968}
969
970fn recipe_zipf(args: &[&str]) -> Result<Vec<f64>, String> {
971    if args.len() != 2 {
972        return Err(format!(
973            "zipf:s,N expects exactly 2 arguments; got {}",
974            args.len()
975        ));
976    }
977    let s = parse_f64_arg(args[0], "zipf")?;
978    let n = parse_u64_arg(args[1], "zipf")?;
979    if s <= 0.0 {
980        return Err(format!("zipf:s,N requires s > 0; got {s}"));
981    }
982    if n == 0 {
983        return Err("zipf:s,N requires N >= 1".into());
984    }
985    weights_for(n, "zipf:s,N", |i| 1.0 / (i as f64).powf(s))
986}
987
988fn recipe_pareto(args: &[&str]) -> Result<Vec<f64>, String> {
989    if args.len() != 2 {
990        return Err(format!(
991            "pareto:alpha,N expects exactly 2 arguments; got {}",
992            args.len()
993        ));
994    }
995    let alpha = parse_f64_arg(args[0], "pareto")?;
996    let n = parse_u64_arg(args[1], "pareto")?;
997    if alpha <= 0.0 {
998        return Err(format!("pareto:alpha,N requires alpha > 0; got {alpha}"));
999    }
1000    if n == 0 {
1001        return Err("pareto:alpha,N requires N >= 1".into());
1002    }
1003    weights_for(n, "pareto:alpha,N", |i| (1.0 / i as f64).powf(alpha))
1004}
1005
1006fn recipe_front_heavy(args: &[&str]) -> Result<Vec<f64>, String> {
1007    if args.len() != 1 {
1008        return Err(format!(
1009            "front_heavy:N expects exactly 1 argument; got {}",
1010            args.len()
1011        ));
1012    }
1013    let n = parse_u64_arg(args[0], "front_heavy")?;
1014    if n == 0 {
1015        return Err("front_heavy:N requires N >= 1".into());
1016    }
1017    // n, n-1, …, 1.
1018    weights_for(n, "front_heavy:N", |i| (n + 1 - i) as f64)
1019}
1020
1021fn recipe_back_heavy(args: &[&str]) -> Result<Vec<f64>, String> {
1022    if args.len() != 1 {
1023        return Err(format!(
1024            "back_heavy:N expects exactly 1 argument; got {}",
1025            args.len()
1026        ));
1027    }
1028    let n = parse_u64_arg(args[0], "back_heavy")?;
1029    if n == 0 {
1030        return Err("back_heavy:N requires N >= 1".into());
1031    }
1032    weights_for(n, "back_heavy:N", |i| i as f64)
1033}
1034
1035/// Normalise raw recipe weights so they sum to 100. Weights
1036/// must be non-negative and have a positive sum.
1037fn normalise_weights(weights: &[f64]) -> Result<Vec<f64>, String> {
1038    if weights.iter().any(|w| !w.is_finite() || *w < 0.0) {
1039        return Err("recipe produced non-finite or negative weights".into());
1040    }
1041    let sum: f64 = weights.iter().sum();
1042    if sum <= 0.0 {
1043        return Err("recipe produced zero total weight".into());
1044    }
1045    Ok(weights.iter().map(|w| w / sum * 100.0).collect())
1046}
1047
1048/// Normalise raw recipe weights to percentage deltas summing
1049/// to 100%.
1050fn normalise_to_pct(weights: &[f64]) -> Result<Vec<Bound>, String> {
1051    Ok(normalise_weights(weights)?
1052        .into_iter()
1053        .map(Bound::Pct)
1054        .collect())
1055}
1056
1057// =========================================================================
1058// Resolution
1059// =========================================================================
1060
1061/// Resolve a [`PartitionSpec`] against a cursor's base extent
1062/// `[base_start, base_end)`, producing a list of concrete
1063/// [`Partition`]s with absolute ordinals.
1064///
1065/// An `in` window narrows the domain first: the window's range
1066/// resolves against the full extent, then the chunking resolves
1067/// against the window (percentages inside the chunking are
1068/// window-relative, and the resulting partitions' `base_extent`
1069/// / pct fields are window-based).
1070///
1071/// For [`Chunking::SingleRange`] the result is always a
1072/// 1-element vector.
1073///
1074/// For [`Chunking::DeltaList`] the deltas are walked
1075/// left-to-right. Gap entries consume extent without emitting.
1076/// Tail tokens consume the unallocated remainder: `Bound::Star`
1077/// absorbs it as one partition, `Bound::Fill` repeats the
1078/// preceding delta until the domain end (final chunk truncated,
1079/// never dropped), `Bound::StarSplit(n)` divides it into `n`
1080/// near-equal partitions, and `Bound::StarShaped(weights)`
1081/// divides it by recipe weights. A sized-delta sum exceeding
1082/// the extent is a hard error.
1083///
1084/// Finally, the spec's [`PartitionOrder`] reorders the list for
1085/// iteration; `idx` keeps identifying the generation position.
1086///
1087/// **Frames:** the window affects *sizing and placement* only —
1088/// percentages inside the chunking are window-relative when
1089/// computing boundaries. The resulting partitions' `start_pct` /
1090/// `end_pct` / `base_extent` are always labelled against the
1091/// **full base frame**, so a windowed partition re-projected
1092/// onto another extent (the `over` clause's cross-extent
1093/// contract) keeps its position in the whole domain instead of
1094/// collapsing the window offset.
1095pub fn resolve(
1096    spec: &PartitionSpec,
1097    base_start: u64,
1098    base_end: u64,
1099) -> Result<Vec<Partition>, String> {
1100    if base_end < base_start {
1101        return Err(format!(
1102            "resolve: base_end ({base_end}) < base_start ({base_start})"
1103        ));
1104    }
1105    let base_extent = base_end - base_start;
1106    // Window: narrow the domain the chunking applies to.
1107    let (dom_start, dom_end) = match &spec.window {
1108        None => (base_start, base_end),
1109        Some((ws, we)) => {
1110            let s = ws
1111                .resolve_against(base_start, base_end)
1112                .expect("window bounds are sized (checked at parse time)");
1113            let e = we
1114                .resolve_against(base_start, base_end)
1115                .expect("window bounds are sized (checked at parse time)");
1116            if e < s {
1117                return Err(format!(
1118                    "window `in {ws}..{we}` is empty or reversed against \
1119                     base=[{base_start}..{base_end}): start={s}, end={e}"
1120                ));
1121            }
1122            (s, e)
1123        }
1124    };
1125    let dom_extent = dom_end - dom_start;
1126    // Labelling frame: pct fields and base_extent always
1127    // describe the full base, regardless of the window.
1128    let frame = Frame {
1129        base_start,
1130        base_extent,
1131    };
1132    let mut partitions = match &spec.chunking {
1133        Chunking::SingleRange { start, end } => {
1134            let start_ord = start
1135                .resolve_against(dom_start, dom_end)
1136                .expect("tail tokens not allowed in SingleRange (checked at parse time)");
1137            let end_ord = end
1138                .resolve_against(dom_start, dom_end)
1139                .expect("tail tokens not allowed in SingleRange (checked at parse time)");
1140            if end_ord < start_ord {
1141                return Err(format!(
1142                    "resolved range is empty or reversed: start={start_ord}, end={end_ord} \
1143                     (spec start={start}, end={end}, base=[{dom_start}..{dom_end}))"
1144                ));
1145            }
1146            // A Form 1 range is an operator-explicit slice; one
1147            // that rounds to zero ordinals would silently run
1148            // nothing — the "why did this do nothing" trap.
1149            // (Delta lists are NOT held to this: auto-
1150            // terminating recipes like `mul:0.5` legitimately
1151            // produce sub-ordinal tail weights on small extents,
1152            // and their zero-width entries iterate zero cycles
1153            // by correct arithmetic. The tail tokens carry their
1154            // own non-empty guards.)
1155            if start_ord == end_ord {
1156                return Err(format!(
1157                    "range `{start}..{end}` resolves to zero ordinals \
1158                     ([{start_ord}..{end_ord}) against base=[{dom_start}..{dom_end})) — \
1159                     the slice rounds to nothing at this extent; widen the range \
1160                     or use a larger extent"
1161                ));
1162            }
1163            vec![frame.partition(0, start_ord, end_ord)]
1164        }
1165        Chunking::DeltaList { deltas } => {
1166            resolve_delta_list(deltas, dom_start, dom_end, dom_extent, frame)?
1167        }
1168    };
1169    // Patch the sibling count now that the list is complete.
1170    let count = partitions.len() as u64;
1171    for p in &mut partitions {
1172        p.count = count;
1173    }
1174    apply_order(&mut partitions, spec);
1175    Ok(partitions)
1176}
1177
1178/// The labelling frame for resolved partitions: pct fields and
1179/// `base_extent` always describe the cursor's full base, even
1180/// when a window narrows where the chunking lands.
1181#[derive(Clone, Copy)]
1182struct Frame {
1183    base_start: u64,
1184    base_extent: u64,
1185}
1186
1187impl Frame {
1188    fn partition(&self, idx: u64, start_ord: u64, end_ord: u64) -> Partition {
1189        // `count` is patched in one post-pass once the full list
1190        // is built (see `resolve`).
1191        Partition {
1192            count: 0,
1193            idx,
1194            start_ord,
1195            end_ord,
1196            start_pct: pct_of(start_ord, self.base_start, self.base_extent),
1197            end_pct: pct_of(end_ord, self.base_start, self.base_extent),
1198            base_extent: self.base_extent,
1199        }
1200    }
1201}
1202
1203/// Reorder a resolved partition list per the spec's order
1204/// keyword. `SmallestFirst` / `LargestFirst` sort by
1205/// **cardinality** (stable — equal-sized partitions keep their
1206/// generation order); `Random` is a deterministic Fisher–Yates
1207/// shuffle seeded from the spec text, so the same spec yields
1208/// the same order on every run. `idx` values are not
1209/// reassigned — they keep identifying the generation position.
1210fn apply_order(partitions: &mut [Partition], spec: &PartitionSpec) {
1211    match spec.order {
1212        PartitionOrder::Unchanged => {}
1213        PartitionOrder::SmallestFirst => {
1214            partitions.sort_by_key(|p| p.cardinality());
1215        }
1216        PartitionOrder::LargestFirst => {
1217            partitions.sort_by_key(|p| std::cmp::Reverse(p.cardinality()));
1218        }
1219        PartitionOrder::Random => {
1220            let mut state = xxhash_rust::xxh3::xxh3_64(format!("{spec:?}").as_bytes());
1221            for i in (1..partitions.len()).rev() {
1222                let j = (splitmix64(&mut state) % (i as u64 + 1)) as usize;
1223                partitions.swap(i, j);
1224            }
1225        }
1226    }
1227}
1228
1229/// SplitMix64 step — the deterministic stream behind
1230/// [`PartitionOrder::Random`].
1231fn splitmix64(state: &mut u64) -> u64 {
1232    *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
1233    let mut z = *state;
1234    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1235    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1236    z ^ (z >> 31)
1237}
1238
1239fn resolve_delta_list(
1240    deltas: &[Bound],
1241    dom_start: u64,
1242    dom_end: u64,
1243    extent: u64,
1244    frame: Frame,
1245) -> Result<Vec<Partition>, String> {
1246    // One boundary rule everywhere: every partition boundary is
1247    // the *exact* cumulative position, rounded once. Sizes are
1248    // boundary differences. This keeps rounding slack from
1249    // accumulating across entries (`linear:3` over 1000 yields
1250    // 333/334/333 covering the extent exactly, not 333/333/333
1251    // with a silently dropped ordinal) and matches Form 1's
1252    // position-rounding and `split_evenly`'s boundary math.
1253    //
1254    // `extent` here is the (possibly windowed) domain the deltas
1255    // size against; `frame` is the full-base labelling frame.
1256    let non_tail_exact: f64 = deltas
1257        .iter()
1258        .filter(|b| !b.is_tail())
1259        .map(|b| delta_exact_ordinals(b, extent))
1260        .sum();
1261    // Float-noise tolerance: a list like `90%,1%,…(×10)` may sum
1262    // to 100% plus epsilon; only reject genuine overshoot.
1263    let tolerance = 1e-6 * (extent as f64).max(1.0);
1264    if non_tail_exact > extent as f64 + tolerance {
1265        return Err(format!(
1266            "delta list sums to {} ordinals, exceeding the cursor's extent {extent}; \
1267             trim the list or use a `*` remainder to absorb the overflow",
1268            non_tail_exact.round() as u64
1269        ));
1270    }
1271    let mut partitions: Vec<Partition> = Vec::with_capacity(deltas.len());
1272    let mut cursor = dom_start;
1273    // Exact running position, in ordinals relative to dom_start.
1274    let mut exact_pos = 0.0f64;
1275    let push = |partitions: &mut Vec<Partition>, start: u64, end: u64| {
1276        let idx = partitions.len() as u64;
1277        partitions.push(frame.partition(idx, start, end));
1278    };
1279    let boundary = |exact_pos: f64| -> u64 { (dom_start + exact_pos.round() as u64).min(dom_end) };
1280    for (i, delta) in deltas.iter().enumerate() {
1281        match delta {
1282            Bound::Star => {
1283                // Absorb exactly what the sized deltas leave.
1284                exact_pos += extent as f64 - non_tail_exact;
1285                let next = boundary(exact_pos);
1286                push(&mut partitions, cursor, next);
1287                cursor = next;
1288            }
1289            Bound::Fill => {
1290                // Parse guarantees Fill is last with a sized
1291                // delta before it; repeat that delta's exact
1292                // size until the extent is used up. The final
1293                // chunk truncates at `dom_end` — emitted
1294                // short, never dropped.
1295                let chunk = delta_exact_ordinals(&deltas[i - 1], extent);
1296                if chunk < 1.0 {
1297                    return Err(format!(
1298                        "fill token `...` would repeat a delta of less than one \
1299                         ordinal (`{}` resolves to {chunk:.3} ordinals against \
1300                         extent {extent})",
1301                        deltas[i - 1]
1302                    ));
1303                }
1304                while cursor < dom_end {
1305                    exact_pos += chunk;
1306                    let next = boundary(exact_pos);
1307                    push(&mut partitions, cursor, next);
1308                    cursor = next;
1309                }
1310            }
1311            Bound::StarSplit(n) => {
1312                // Parse guarantees StarSplit is last; what's
1313                // left of the extent is split into n near-equal
1314                // partitions.
1315                let remainder = dom_end - cursor;
1316                if remainder == 0 {
1317                    return Err(format!(
1318                        "`*/{n}` has no remainder to divide — the preceding deltas \
1319                         already cover the extent {extent}"
1320                    ));
1321                }
1322                if *n > remainder {
1323                    return Err(format!(
1324                        "`*/{n}` cannot divide a remainder of {remainder} ordinals \
1325                         into {n} non-empty partitions"
1326                    ));
1327                }
1328                for (s, e) in try_split_evenly(cursor, dom_end, *n)? {
1329                    push(&mut partitions, s, e);
1330                }
1331                cursor = dom_end;
1332            }
1333            Bound::StarShaped(weights) => {
1334                // Parse guarantees StarShaped is last; what's
1335                // left of the extent is divided by the recipe's
1336                // normalised weights (cumulative-position
1337                // rounding, same rule as everything else).
1338                let remainder = dom_end - cursor;
1339                if remainder == 0 {
1340                    return Err(format!(
1341                        "`*/<recipe>` has no remainder to divide — the preceding \
1342                         deltas already cover the extent {extent}"
1343                    ));
1344                }
1345                let start = cursor;
1346                let mut cum = 0.0f64;
1347                for w in weights {
1348                    cum += w;
1349                    let next =
1350                        (start + ((cum / 100.0) * remainder as f64).round() as u64).min(dom_end);
1351                    if next == cursor {
1352                        return Err(format!(
1353                            "`*/<recipe>` produces an empty partition — weight \
1354                             {w:.3}% of a {remainder}-ordinal remainder rounds to \
1355                             zero ordinals; use fewer/coarser weights or a larger \
1356                             remainder"
1357                        ));
1358                    }
1359                    push(&mut partitions, cursor, next);
1360                    cursor = next;
1361                }
1362                exact_pos += remainder as f64;
1363            }
1364            Bound::Gap(inner) => {
1365                // Consume the gap's extent without emitting a
1366                // partition. The walk stays contiguous; the
1367                // emitted set skips this range.
1368                exact_pos += delta_exact_ordinals(inner, extent);
1369                cursor = boundary(exact_pos);
1370            }
1371            other => {
1372                exact_pos += delta_exact_ordinals(other, extent);
1373                let next = boundary(exact_pos);
1374                push(&mut partitions, cursor, next);
1375                cursor = next;
1376            }
1377        }
1378    }
1379    // Trailing-gap policy: deltas summing to less than the
1380    // extent (without a tail token) drop the gap. `cursor` may
1381    // end short of `dom_end` — that's intentional, not an error.
1382    debug_assert!(cursor <= dom_end);
1383    Ok(partitions)
1384}
1385
1386/// Convert a delta `Bound` to its *exact* size in ordinals
1387/// against an extent (unrounded — boundaries round once, at the
1388/// cumulative position). A gap's size is its wrapped bound's.
1389/// Tail tokens are the caller's responsibility (their sizes
1390/// depend on the sized deltas).
1391fn delta_exact_ordinals(b: &Bound, extent: u64) -> f64 {
1392    match b {
1393        Bound::Pct(p) => (p / 100.0) * extent as f64,
1394        Bound::Frac(f) => f * extent as f64,
1395        Bound::Ord(o) => *o as f64,
1396        Bound::Gap(inner) => delta_exact_ordinals(inner, extent),
1397        Bound::Star | Bound::Fill | Bound::StarSplit(_) | Bound::StarShaped(_) => {
1398            unreachable!("tail tokens handled separately")
1399        }
1400    }
1401}
1402
1403/// Split a resolved [`Partition`] into `n` contiguous
1404/// sub-partitions whose sizes differ by at most one ordinal —
1405/// the value-level form of the `*/N` spec token (identical
1406/// boundary math via [`split_evenly`]). Indices restart at 0,
1407/// `count` is `n`, `base_extent` propagates, and the pct fields
1408/// interpolate the parent's span.
1409///
1410/// Errors when `n` is 0 or exceeds the partition's cardinality
1411/// (every sub-partition must be non-empty). This is the shared
1412/// engine behind the `subdivide(p, n)` node in `polydat-nodes` (which
1413/// panics per node convention) and the comprehension-source
1414/// form (`for: "inner in subdivide(outer, n)"`, which surfaces
1415/// the error as a clause diagnostic).
1416pub fn subdivide_partition(p: &Partition, n: u64) -> Result<Vec<Partition>, String> {
1417    let card = p.cardinality();
1418    if n == 0 {
1419        return Err("subdivide(p, 0): the sub-partition count must be >= 1".into());
1420    }
1421    if n > card {
1422        return Err(format!(
1423            "subdivide(p, {n}): cannot divide partition #{} of {card} ordinals \
1424             into {n} non-empty sub-partitions",
1425            p.idx
1426        ));
1427    }
1428    let pct_at = |ord: u64| -> f64 {
1429        p.start_pct + (ord - p.start_ord) as f64 / card as f64 * (p.end_pct - p.start_pct)
1430    };
1431    Ok(try_split_evenly(p.start_ord, p.end_ord, n)?
1432        .into_iter()
1433        .enumerate()
1434        .map(|(i, (start_ord, end_ord))| Partition {
1435            idx: i as u64,
1436            count: n,
1437            start_ord,
1438            end_ord,
1439            start_pct: pct_at(start_ord),
1440            end_pct: pct_at(end_ord),
1441            base_extent: p.base_extent,
1442        })
1443        .collect())
1444}
1445
1446/// Split `[start_ord, end_ord)` into `n` contiguous half-open
1447/// chunks whose sizes differ by at most one ordinal. Boundary
1448/// `i` sits at `start_ord + round(i * span / n)`, so the
1449/// rounding slack is distributed across the chunks rather than
1450/// accumulating in the last one. `n` must be >= 1.
1451///
1452/// Shared between the `*/N` spec tail token and the
1453/// `subdivide(p, n)` node in `polydat-nodes` so both produce identical
1454/// boundaries.
1455pub fn split_evenly(start_ord: u64, end_ord: u64, n: u64) -> Vec<(u64, u64)> {
1456    try_split_evenly(start_ord, end_ord, n).unwrap_or_else(|e| panic!("{e}"))
1457}
1458
1459/// [`split_evenly`], refusing a chunk count that cannot be held. The
1460/// count is bounded by the ordinals in the span, not by memory:
1461/// `subdivide(p, 2^50)` over a large enough domain is well formed and
1462/// asks for 2^50 chunks, so it is refused here rather than aborting in
1463/// the allocator.
1464pub fn try_split_evenly(start_ord: u64, end_ord: u64, n: u64) -> Result<Vec<(u64, u64)>, String> {
1465    debug_assert!(n >= 1, "split_evenly requires n >= 1");
1466    debug_assert!(end_ord >= start_ord);
1467    let span = (end_ord - start_ord) as u128;
1468    let n_wide = n as u128;
1469    let boundary =
1470        |i: u64| -> u64 { start_ord + ((i as u128 * span + n_wide / 2) / n_wide) as u64 };
1471    let mut out = crate::derive_support::try_buffer_for(n, "split into n partitions")?;
1472    out.extend((0..n).map(|i| (boundary(i), boundary(i + 1))));
1473    Ok(out)
1474}
1475
1476#[inline]
1477fn pct_of(ordinal: u64, base_start: u64, extent: u64) -> f64 {
1478    if extent == 0 {
1479        0.0
1480    } else {
1481        (ordinal - base_start) as f64 * 100.0 / extent as f64
1482    }
1483}
1484
1485// =========================================================================
1486// Polydat Value integration
1487// =========================================================================
1488//
1489// Partition and PartitionSpec carry through Polydat wires as
1490// `Value::Ext(Box<dyn ReflectedValue>)` rather than dedicated
1491// enum variants. This avoids sweeping every Value-match site in
1492// the codebase. Stdlib node functions that consume partitions
1493// downcast via [`Value::as_partition`] / [`Value::as_partition_spec`]
1494// at their entry points.
1495
1496impl ReflectedValue for Partition {
1497    fn type_name(&self) -> &str {
1498        "Partition"
1499    }
1500
1501    fn display(&self) -> String {
1502        format!(
1503            "Partition({}/{} [{}..{}) [{:.2}%..{:.2}%))",
1504            self.idx, self.count, self.start_ord, self.end_ord, self.start_pct, self.end_pct,
1505        )
1506    }
1507
1508    fn to_json_value(&self) -> serde_json::Value {
1509        serde_json::json!({
1510            "idx":         self.idx,
1511            "count":       self.count,
1512            "start_ord":   self.start_ord,
1513            "end_ord":     self.end_ord,
1514            "start_pct":   self.start_pct,
1515            "end_pct":     self.end_pct,
1516            "base_extent": self.base_extent,
1517            "cardinality": self.cardinality(),
1518        })
1519    }
1520
1521    fn as_any(&self) -> &dyn std::any::Any {
1522        self
1523    }
1524
1525    fn clone_reflected(&self) -> Box<dyn ReflectedValue> {
1526        Box::new(*self)
1527    }
1528}
1529
1530impl ReflectedValue for PartitionSpec {
1531    fn type_name(&self) -> &str {
1532        "PartitionSpec"
1533    }
1534
1535    fn display(&self) -> String {
1536        let chunking = match &self.chunking {
1537            Chunking::SingleRange { start, end } => format!("{start}..{end}"),
1538            Chunking::DeltaList { deltas } => {
1539                let parts: Vec<String> = deltas.iter().map(|b| b.to_string()).collect();
1540                parts.join(",")
1541            }
1542        };
1543        let window = match &self.window {
1544            Some((s, e)) => format!(" in {s}..{e}"),
1545            None => String::new(),
1546        };
1547        let order = match self.order {
1548            PartitionOrder::Unchanged => String::new(),
1549            o => format!(" {o}"),
1550        };
1551        format!("PartitionSpec({chunking}{window}{order})")
1552    }
1553
1554    fn to_json_value(&self) -> serde_json::Value {
1555        serde_json::Value::String(self.display())
1556    }
1557
1558    fn as_any(&self) -> &dyn std::any::Any {
1559        self
1560    }
1561
1562    fn clone_reflected(&self) -> Box<dyn ReflectedValue> {
1563        Box::new(self.clone())
1564    }
1565}
1566
1567/// A list of resolved partitions carried as a single Polydat value.
1568///
1569/// Needed because [`Value::Ext`] holds one [`ReflectedValue`] —
1570/// to flow a `Vec<Partition>` on a single wire we wrap it once
1571/// here. Backed by `Arc` so cloning is one atomic increment.
1572#[derive(Debug, Clone)]
1573pub struct PartitionList(pub Arc<Vec<Partition>>);
1574
1575impl PartitionList {
1576    /// A list of the given partitions.
1577    pub fn new(partitions: Vec<Partition>) -> Self {
1578        Self(Arc::new(partitions))
1579    }
1580
1581    /// Number of partitions in the list.
1582    pub fn len(&self) -> usize {
1583        self.0.len()
1584    }
1585
1586    /// True if the list is empty.
1587    pub fn is_empty(&self) -> bool {
1588        self.0.is_empty()
1589    }
1590
1591    /// Borrow the underlying slice for iteration.
1592    pub fn as_slice(&self) -> &[Partition] {
1593        &self.0
1594    }
1595}
1596
1597impl ReflectedValue for PartitionList {
1598    fn type_name(&self) -> &str {
1599        "PartitionList"
1600    }
1601
1602    fn display(&self) -> String {
1603        let parts: Vec<String> = self
1604            .0
1605            .iter()
1606            .map(|p| format!("[{}..{})", p.start_ord, p.end_ord))
1607            .collect();
1608        format!("PartitionList[{}]={}", self.0.len(), parts.join(","))
1609    }
1610
1611    fn to_json_value(&self) -> serde_json::Value {
1612        serde_json::Value::Array(self.0.iter().map(|p| p.to_json_value()).collect())
1613    }
1614
1615    fn as_any(&self) -> &dyn std::any::Any {
1616        self
1617    }
1618
1619    fn clone_reflected(&self) -> Box<dyn ReflectedValue> {
1620        Box::new(self.clone())
1621    }
1622}
1623
1624/// Convenience constructors and downcasters on [`Value`] for
1625/// partition-typed wires. Use these at node entry / exit to
1626/// avoid `Value::Ext(Box::new(...))` boilerplate.
1627impl Value {
1628    /// Wrap a [`Partition`] as a Polydat `Value::Ext`.
1629    pub fn from_partition(p: Partition) -> Self {
1630        Value::Ext(Box::new(p))
1631    }
1632
1633    /// Wrap a [`PartitionSpec`] as a Polydat `Value::Ext`.
1634    pub fn from_partition_spec(s: PartitionSpec) -> Self {
1635        Value::Ext(Box::new(s))
1636    }
1637
1638    /// Wrap a `Vec<Partition>` as a Polydat `Value::Ext` via
1639    /// [`PartitionList`]. Use this when a wire needs to carry
1640    /// the whole resolved list (e.g. the `<param>.partitions`
1641    /// projection).
1642    pub fn from_partition_list(parts: Vec<Partition>) -> Self {
1643        Value::Ext(Box::new(PartitionList::new(parts)))
1644    }
1645
1646    /// Downcast to a [`Partition`] reference. Returns `None` if
1647    /// the value isn't a partition.
1648    pub fn as_partition(&self) -> Option<&Partition> {
1649        match self {
1650            Value::Ext(b) => b.as_any().downcast_ref::<Partition>(),
1651            _ => None,
1652        }
1653    }
1654
1655    /// Downcast to a [`PartitionSpec`] reference. Returns `None`
1656    /// if the value isn't a spec.
1657    pub fn as_partition_spec(&self) -> Option<&PartitionSpec> {
1658        match self {
1659            Value::Ext(b) => b.as_any().downcast_ref::<PartitionSpec>(),
1660            _ => None,
1661        }
1662    }
1663
1664    /// Downcast to a [`PartitionList`] reference. Returns `None`
1665    /// if the value isn't a partition list.
1666    pub fn as_partition_list(&self) -> Option<&PartitionList> {
1667        match self {
1668            Value::Ext(b) => b.as_any().downcast_ref::<PartitionList>(),
1669            _ => None,
1670        }
1671    }
1672}
1673
1674// =========================================================================
1675// Tests
1676// =========================================================================
1677
1678#[cfg(test)]
1679mod tests {
1680    use super::*;
1681
1682    // ── Number-form parsing ────────────────────────────────
1683
1684    #[test]
1685    fn parse_bound_percentage() {
1686        assert_eq!(parse_bound("53%").unwrap(), Bound::Pct(53.0));
1687        assert_eq!(parse_bound("0%").unwrap(), Bound::Pct(0.0));
1688        assert_eq!(parse_bound("100%").unwrap(), Bound::Pct(100.0));
1689        assert_eq!(parse_bound("0.5%").unwrap(), Bound::Pct(0.5));
1690    }
1691
1692    #[test]
1693    fn parse_bound_percentage_out_of_range_rejected() {
1694        assert!(parse_bound("101%").is_err());
1695        assert!(parse_bound("-1%").is_err());
1696    }
1697
1698    #[test]
1699    fn parse_bound_fraction() {
1700        assert_eq!(parse_bound("0.5").unwrap(), Bound::Frac(0.5));
1701        assert_eq!(parse_bound("0.0").unwrap(), Bound::Frac(0.0));
1702        assert_eq!(parse_bound("1.0").unwrap(), Bound::Frac(1.0));
1703        assert_eq!(parse_bound("0.123").unwrap(), Bound::Frac(0.123));
1704    }
1705
1706    #[test]
1707    fn parse_bound_fraction_out_of_range_rejected() {
1708        let err = parse_bound("1.5").unwrap_err();
1709        assert!(
1710            err.contains("ambiguous"),
1711            "diagnostic should explain: {err}"
1712        );
1713    }
1714
1715    #[test]
1716    fn parse_bound_literal_ordinal() {
1717        assert_eq!(parse_bound("0").unwrap(), Bound::Ord(0));
1718        assert_eq!(parse_bound("100").unwrap(), Bound::Ord(100));
1719        assert_eq!(parse_bound("999999").unwrap(), Bound::Ord(999_999));
1720    }
1721
1722    #[test]
1723    fn parse_bound_star_token() {
1724        assert_eq!(parse_bound("*").unwrap(), Bound::Star);
1725        assert_eq!(parse_bound("*%").unwrap(), Bound::Star);
1726    }
1727
1728    // ── Form 1: single sub-range ───────────────────────────
1729
1730    #[test]
1731    fn parse_form1_simple_pct() {
1732        let spec = parse("0..53%").unwrap();
1733        assert_eq!(
1734            spec,
1735            PartitionSpec::single_range(Bound::Ord(0), Bound::Pct(53.0))
1736        );
1737    }
1738
1739    #[test]
1740    fn parse_form1_brackets_tolerated() {
1741        let canonical = PartitionSpec::single_range(Bound::Ord(0), Bound::Pct(53.0));
1742        assert_eq!(parse("[0..53%]").unwrap(), canonical);
1743        assert_eq!(parse("[0..53%)").unwrap(), canonical);
1744        assert_eq!(parse("(0..53%]").unwrap(), canonical);
1745    }
1746
1747    #[test]
1748    fn parse_form1_fraction_form() {
1749        let spec = parse("0..0.53").unwrap();
1750        assert_eq!(
1751            spec,
1752            PartitionSpec::single_range(Bound::Ord(0), Bound::Frac(0.53))
1753        );
1754    }
1755
1756    #[test]
1757    fn parse_form1_literal_ordinals() {
1758        let spec = parse("100..1000").unwrap();
1759        assert_eq!(
1760            spec,
1761            PartitionSpec::single_range(Bound::Ord(100), Bound::Ord(1000))
1762        );
1763    }
1764
1765    #[test]
1766    fn parse_form1_mixed_literal_and_pct() {
1767        let spec = parse("100..50%").unwrap();
1768        assert_eq!(
1769            spec,
1770            PartitionSpec::single_range(Bound::Ord(100), Bound::Pct(50.0))
1771        );
1772    }
1773
1774    #[test]
1775    fn parse_form1_mixed_frac_and_literal() {
1776        let spec = parse("0.10..10000").unwrap();
1777        assert_eq!(
1778            spec,
1779            PartitionSpec::single_range(Bound::Frac(0.10), Bound::Ord(10000))
1780        );
1781    }
1782
1783    #[test]
1784    fn parse_form1_rejects_star() {
1785        assert!(parse("0..*").is_err());
1786        assert!(parse("*..50%").is_err());
1787    }
1788
1789    // ── Form 2: delta list ─────────────────────────────────
1790
1791    #[test]
1792    fn parse_form2_with_star() {
1793        let spec = parse("2%,10%,*%").unwrap();
1794        assert_eq!(
1795            spec,
1796            PartitionSpec::delta_list(vec![Bound::Pct(2.0), Bound::Pct(10.0), Bound::Star])
1797        );
1798    }
1799
1800    #[test]
1801    fn parse_form2_fraction_equivalent() {
1802        let spec = parse("0.02,0.10,*").unwrap();
1803        assert_eq!(
1804            spec,
1805            PartitionSpec::delta_list(vec![Bound::Frac(0.02), Bound::Frac(0.10), Bound::Star])
1806        );
1807    }
1808
1809    #[test]
1810    fn parse_form2_literal_deltas() {
1811        let spec = parse("1000,5000,*").unwrap();
1812        assert_eq!(
1813            spec,
1814            PartitionSpec::delta_list(vec![Bound::Ord(1000), Bound::Ord(5000), Bound::Star])
1815        );
1816    }
1817
1818    #[test]
1819    fn parse_form2_mixed_entries() {
1820        let spec = parse("1000,10%,*").unwrap();
1821        assert_eq!(
1822            spec,
1823            PartitionSpec::delta_list(vec![Bound::Ord(1000), Bound::Pct(10.0), Bound::Star])
1824        );
1825    }
1826
1827    #[test]
1828    fn parse_form2_short_list_no_star() {
1829        let spec = parse("20%,30%").unwrap();
1830        assert_eq!(
1831            spec,
1832            PartitionSpec::delta_list(vec![Bound::Pct(20.0), Bound::Pct(30.0)])
1833        );
1834    }
1835
1836    #[test]
1837    fn parse_form2_rejects_multiple_stars() {
1838        let err = parse("*,*").unwrap_err();
1839        assert!(err.contains("at most one"), "diagnostic: {err}");
1840    }
1841
1842    // ── Form 2 tail tokens: `...` fill and `*/N` split ─────
1843
1844    #[test]
1845    fn parse_form2_fill_token() {
1846        let spec = parse("90%,1%,...").unwrap();
1847        assert_eq!(
1848            spec,
1849            PartitionSpec::delta_list(vec![Bound::Pct(90.0), Bound::Pct(1.0), Bound::Fill])
1850        );
1851    }
1852
1853    #[test]
1854    fn parse_form2_star_split_token() {
1855        let spec = parse("90%,*/10").unwrap();
1856        assert_eq!(
1857            spec,
1858            PartitionSpec::delta_list(vec![Bound::Pct(90.0), Bound::StarSplit(10)])
1859        );
1860    }
1861
1862    #[test]
1863    fn parse_star_split_alone_is_whole_extent_split() {
1864        // Degenerate no-head case: the remainder is everything.
1865        let spec = parse("*/16").unwrap();
1866        assert_eq!(spec, PartitionSpec::delta_list(vec![Bound::StarSplit(16)]));
1867    }
1868
1869    #[test]
1870    fn parse_fill_alone_rejected_with_hint() {
1871        let err = parse("...").unwrap_err();
1872        assert!(
1873            err.contains("preceding delta") || err.contains("before it"),
1874            "diagnostic: {err}"
1875        );
1876    }
1877
1878    #[test]
1879    fn parse_fill_first_in_list_rejected() {
1880        let err = parse("...,10%").unwrap_err();
1881        assert!(
1882            err.contains("before it") || err.contains("last entry"),
1883            "diagnostic: {err}"
1884        );
1885    }
1886
1887    #[test]
1888    fn parse_fill_not_last_rejected() {
1889        let err = parse("1%,...,10%").unwrap_err();
1890        assert!(err.contains("last entry"), "diagnostic: {err}");
1891    }
1892
1893    #[test]
1894    fn parse_star_split_not_last_rejected() {
1895        let err = parse("*/4,10%").unwrap_err();
1896        assert!(err.contains("last entry"), "diagnostic: {err}");
1897    }
1898
1899    #[test]
1900    fn parse_rejects_mixed_tail_tokens() {
1901        let err = parse("1%,*,...").unwrap_err();
1902        assert!(err.contains("at most one"), "diagnostic: {err}");
1903        let err = parse("1%,*,*/4").unwrap_err();
1904        assert!(err.contains("at most one"), "diagnostic: {err}");
1905    }
1906
1907    #[test]
1908    fn parse_star_split_pct_divisor_rejected_with_teaching_hint() {
1909        // `*/1%` is the chunk-SIZE reading; one canonical
1910        // spelling for that exists (`1%,...`), and the
1911        // diagnostic must point at it.
1912        let err = parse("90%,*/1%").unwrap_err();
1913        assert!(err.contains("chunk count"), "diagnostic: {err}");
1914        assert!(
1915            err.contains("1%,..."),
1916            "diagnostic should teach the fill form: {err}"
1917        );
1918        let err = parse("90%,*/0.01").unwrap_err();
1919        assert!(err.contains("chunk count"), "diagnostic: {err}");
1920    }
1921
1922    #[test]
1923    fn parse_star_split_zero_rejected() {
1924        let err = parse("90%,*/0").unwrap_err();
1925        assert!(err.contains(">= 1"), "diagnostic: {err}");
1926    }
1927
1928    #[test]
1929    fn parse_form1_rejects_tail_tokens() {
1930        assert!(parse("0..*/4").is_err());
1931        // `0....` reads as `0..` + `..` noise — any tail token in
1932        // a range position must fail to parse, one way or another.
1933        assert!(parse("0....").is_err());
1934    }
1935
1936    // ── Form 3: pre-baked recipes ──────────────────────────
1937
1938    fn deltas_only(spec: PartitionSpec) -> Vec<Bound> {
1939        match spec.chunking {
1940            Chunking::DeltaList { deltas } => deltas,
1941            other => panic!("expected DeltaList, got {other:?}"),
1942        }
1943    }
1944
1945    fn pcts_of(spec: PartitionSpec) -> Vec<f64> {
1946        deltas_only(spec)
1947            .into_iter()
1948            .map(|b| match b {
1949                Bound::Pct(p) => p,
1950                other => panic!("expected Pct, got {other:?}"),
1951            })
1952            .collect()
1953    }
1954
1955    #[test]
1956    fn recipe_linear_uniform_split() {
1957        let pcts = pcts_of(parse("linear:4").unwrap());
1958        assert_eq!(pcts.len(), 4);
1959        for p in &pcts {
1960            assert!((p - 25.0).abs() < 1e-9, "expected 25%, got {p}");
1961        }
1962    }
1963
1964    #[test]
1965    fn recipe_ratios_normalises_weights() {
1966        let pcts = pcts_of(parse("ratios:1,1,2").unwrap());
1967        assert_eq!(pcts.len(), 3);
1968        assert!((pcts[0] - 25.0).abs() < 1e-9);
1969        assert!((pcts[1] - 25.0).abs() < 1e-9);
1970        assert!((pcts[2] - 50.0).abs() < 1e-9);
1971    }
1972
1973    #[test]
1974    fn recipe_bin_5_is_five_terms_of_binomial_expansion() {
1975        // C(4, k) for k = 0..4 → [1, 4, 6, 4, 1], sum 16.
1976        let pcts = pcts_of(parse("bin:5").unwrap());
1977        assert_eq!(pcts.len(), 5);
1978        let expected = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
1979        for (i, e) in expected.iter().enumerate() {
1980            assert!(
1981                (pcts[i] - e * 100.0).abs() < 1e-9,
1982                "term {i}: {} vs {}",
1983                pcts[i],
1984                e * 100.0
1985            );
1986        }
1987    }
1988
1989    #[test]
1990    fn recipe_fib_7_uses_distinct_fibonacci() {
1991        // 1, 2, 3, 5, 8, 13, 21 — sum 53.
1992        let pcts = pcts_of(parse("fib:7").unwrap());
1993        assert_eq!(pcts.len(), 7);
1994        let expected_weights = [1.0, 2.0, 3.0, 5.0, 8.0, 13.0, 21.0];
1995        let sum: f64 = expected_weights.iter().sum();
1996        for (i, w) in expected_weights.iter().enumerate() {
1997            assert!((pcts[i] - w / sum * 100.0).abs() < 1e-9);
1998        }
1999    }
2000
2001    #[test]
2002    fn recipe_ln_5_log_spaced() {
2003        let pcts = pcts_of(parse("ln:5").unwrap());
2004        assert_eq!(pcts.len(), 5);
2005        // Monotonically increasing weights.
2006        for i in 1..pcts.len() {
2007            assert!(pcts[i] > pcts[i - 1], "ln:N should be monotonic");
2008        }
2009        // Sum to 100.
2010        let total: f64 = pcts.iter().sum();
2011        assert!((total - 100.0).abs() < 1e-9, "total: {total}");
2012    }
2013
2014    #[test]
2015    fn recipe_mul_decay_tail_off() {
2016        // Decay case: R < 1, terms shrink. Stop when current
2017        // term is < 0.1% of the starting weight.
2018        let pcts = pcts_of(parse("mul:0.5").unwrap());
2019        assert!(!pcts.is_empty());
2020        let total: f64 = pcts.iter().sum();
2021        assert!((total - 100.0).abs() < 1e-9, "total: {total}");
2022        // 1, 0.5, 0.25, ... — first partition should be the dominant one.
2023        assert!(pcts[0] > pcts[1]);
2024    }
2025
2026    #[test]
2027    fn recipe_mul_growth_caps_at_3_orders_of_magnitude() {
2028        // Growth case: R > 1, terms grow. Stop when terms span
2029        // ~3 orders of magnitude. Sum normalises cleanly.
2030        let pcts = pcts_of(parse("mul:2").unwrap());
2031        assert!(!pcts.is_empty());
2032        assert!(pcts.len() < 64, "should terminate well before hard cap");
2033        let total: f64 = pcts.iter().sum();
2034        assert!((total - 100.0).abs() < 1e-9, "total: {total}");
2035    }
2036
2037    #[test]
2038    fn recipe_mul_with_start_and_ratio() {
2039        // mul:S,R — start at S, compound by R.
2040        let pcts = pcts_of(parse("mul:5,0.5").unwrap());
2041        let total: f64 = pcts.iter().sum();
2042        assert!((total - 100.0).abs() < 1e-9, "total: {total}");
2043    }
2044
2045    #[test]
2046    fn recipe_geom_fixed_term_count() {
2047        let pcts = pcts_of(parse("geom:5,2").unwrap());
2048        assert_eq!(pcts.len(), 5);
2049        // Weights are 1, 2, 4, 8, 16 — sum 31.
2050        let expected_total: f64 = 31.0;
2051        let expected = [1.0, 2.0, 4.0, 8.0, 16.0];
2052        for (i, e) in expected.iter().enumerate() {
2053            assert!((pcts[i] - e / expected_total * 100.0).abs() < 1e-9);
2054        }
2055    }
2056
2057    #[test]
2058    fn recipe_front_heavy_declining() {
2059        let pcts = pcts_of(parse("front_heavy:4").unwrap());
2060        assert_eq!(pcts.len(), 4);
2061        for i in 1..pcts.len() {
2062            assert!(
2063                pcts[i] < pcts[i - 1],
2064                "front_heavy should be monotonic-declining"
2065            );
2066        }
2067    }
2068
2069    #[test]
2070    fn recipe_back_heavy_growing() {
2071        let pcts = pcts_of(parse("back_heavy:4").unwrap());
2072        assert_eq!(pcts.len(), 4);
2073        for i in 1..pcts.len() {
2074            assert!(
2075                pcts[i] > pcts[i - 1],
2076                "back_heavy should be monotonic-growing"
2077            );
2078        }
2079    }
2080
2081    #[test]
2082    fn recipe_unknown_name_rejected() {
2083        let err = parse("blorp:3").unwrap_err();
2084        assert!(err.contains("unknown recipe"), "diagnostic: {err}");
2085        assert!(
2086            err.contains("linear"),
2087            "should list supported recipes: {err}"
2088        );
2089    }
2090
2091    // ── Resolution ──────────────────────────────────────────
2092
2093    #[test]
2094    fn resolve_form1_percentage_against_extent() {
2095        let spec = parse("0..50%").unwrap();
2096        let parts = resolve(&spec, 0, 1000).unwrap();
2097        assert_eq!(parts.len(), 1);
2098        assert_eq!(parts[0].start_ord, 0);
2099        assert_eq!(parts[0].end_ord, 500);
2100        assert_eq!(parts[0].cardinality(), 500);
2101    }
2102
2103    #[test]
2104    fn resolve_form1_literal_ordinals() {
2105        let spec = parse("100..1000").unwrap();
2106        let parts = resolve(&spec, 0, 10000).unwrap();
2107        assert_eq!(parts[0].start_ord, 100);
2108        assert_eq!(parts[0].end_ord, 1000);
2109        assert_eq!(parts[0].cardinality(), 900);
2110    }
2111
2112    #[test]
2113    fn resolve_form1_mixed_literal_and_pct() {
2114        let spec = parse("100..50%").unwrap();
2115        let parts = resolve(&spec, 0, 1000).unwrap();
2116        assert_eq!(parts[0].start_ord, 100);
2117        assert_eq!(parts[0].end_ord, 500);
2118    }
2119
2120    #[test]
2121    fn resolve_form2_three_partition_pct_list() {
2122        let spec = parse("2%,10%,*%").unwrap();
2123        let parts = resolve(&spec, 0, 1000).unwrap();
2124        assert_eq!(parts.len(), 3);
2125        assert_eq!(parts[0].start_ord, 0);
2126        assert_eq!(parts[0].end_ord, 20);
2127        assert_eq!(parts[1].start_ord, 20);
2128        assert_eq!(parts[1].end_ord, 120);
2129        assert_eq!(parts[2].start_ord, 120);
2130        assert_eq!(parts[2].end_ord, 1000);
2131        assert_eq!(parts[2].cardinality(), 880);
2132    }
2133
2134    #[test]
2135    fn resolve_form2_literal_deltas() {
2136        let spec = parse("1000,5000,*").unwrap();
2137        let parts = resolve(&spec, 0, 10000).unwrap();
2138        assert_eq!(parts.len(), 3);
2139        assert_eq!(parts[0].start_ord, 0);
2140        assert_eq!(parts[0].end_ord, 1000);
2141        assert_eq!(parts[1].start_ord, 1000);
2142        assert_eq!(parts[1].end_ord, 6000);
2143        assert_eq!(parts[2].start_ord, 6000);
2144        assert_eq!(parts[2].end_ord, 10000);
2145    }
2146
2147    #[test]
2148    fn resolve_form2_mixed_literal_and_pct_with_star() {
2149        let spec = parse("1000,10%,*").unwrap();
2150        let parts = resolve(&spec, 0, 10000).unwrap();
2151        assert_eq!(parts.len(), 3);
2152        assert_eq!(parts[0].cardinality(), 1000);
2153        assert_eq!(parts[1].cardinality(), 1000); // 10% of 10000
2154        assert_eq!(parts[2].cardinality(), 8000); // remainder
2155    }
2156
2157    #[test]
2158    fn resolve_form2_short_list_drops_trailing_gap() {
2159        let spec = parse("20%,30%").unwrap();
2160        let parts = resolve(&spec, 0, 1000).unwrap();
2161        assert_eq!(parts.len(), 2);
2162        assert_eq!(parts[0].end_ord, 200);
2163        assert_eq!(parts[1].end_ord, 500); // 50% boundary; trailing 50% gap dropped
2164    }
2165
2166    #[test]
2167    fn resolve_rejects_over_extent_sum() {
2168        let spec = parse("60%,60%").unwrap();
2169        let err = resolve(&spec, 0, 1000).unwrap_err();
2170        assert!(err.contains("exceeding"), "diagnostic: {err}");
2171    }
2172
2173    #[test]
2174    fn resolve_recipe_against_extent() {
2175        let spec = parse("linear:4").unwrap();
2176        let parts = resolve(&spec, 0, 1000).unwrap();
2177        assert_eq!(parts.len(), 4);
2178        for p in &parts {
2179            assert_eq!(p.cardinality(), 250);
2180        }
2181    }
2182
2183    #[test]
2184    fn resolve_partition_indices_assigned() {
2185        let spec = parse("linear:5").unwrap();
2186        let parts = resolve(&spec, 0, 1000).unwrap();
2187        for (i, p) in parts.iter().enumerate() {
2188            assert_eq!(p.idx, i as u64);
2189        }
2190    }
2191
2192    #[test]
2193    fn resolve_partition_pcts_populated() {
2194        let spec = parse("linear:4").unwrap();
2195        let parts = resolve(&spec, 0, 1000).unwrap();
2196        assert!((parts[0].start_pct - 0.0).abs() < 1e-9);
2197        assert!((parts[0].end_pct - 25.0).abs() < 1e-9);
2198        assert!((parts[3].end_pct - 100.0).abs() < 1e-9);
2199    }
2200
2201    // ── Resolution: tail tokens ─────────────────────────────
2202
2203    /// The three spellings of "first 90%, then the rest in ten
2204    /// 1%-of-the-whole chunks" that coincide at head=90%:
2205    /// explicit enumeration, fill, and remainder split.
2206    #[test]
2207    fn resolve_fill_and_star_split_coincide_at_90_10() {
2208        let explicit = resolve(
2209            &parse("90%,1%,1%,1%,1%,1%,1%,1%,1%,1%,1%").unwrap(),
2210            0,
2211            1000,
2212        )
2213        .unwrap();
2214        let filled = resolve(&parse("90%,1%,...").unwrap(), 0, 1000).unwrap();
2215        let split = resolve(&parse("90%,*/10").unwrap(), 0, 1000).unwrap();
2216        assert_eq!(explicit.len(), 11);
2217        assert_eq!(filled, explicit);
2218        assert_eq!(split, explicit);
2219        assert_eq!(filled[0].cardinality(), 900);
2220        for p in &filled[1..] {
2221            assert_eq!(p.cardinality(), 10);
2222        }
2223        assert_eq!(filled[10].end_ord, 1000);
2224    }
2225
2226    #[test]
2227    fn resolve_fill_truncates_final_chunk() {
2228        // 3 + 2 + 2 + 2 + 1(truncated) over extent 10.
2229        let parts = resolve(&parse("3,2,...").unwrap(), 0, 10).unwrap();
2230        let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2231        assert_eq!(bounds, vec![(0, 3), (3, 5), (5, 7), (7, 9), (9, 10)]);
2232    }
2233
2234    #[test]
2235    fn resolve_fill_with_nothing_left_adds_no_chunks() {
2236        // 90% + 10% covers the extent; `...` repeats 10% zero times.
2237        let parts = resolve(&parse("90%,10%,...").unwrap(), 0, 1000).unwrap();
2238        assert_eq!(parts.len(), 2);
2239        assert_eq!(parts[1].end_ord, 1000);
2240    }
2241
2242    #[test]
2243    fn resolve_fill_subordinal_chunk_rejected() {
2244        // 0.01% of 100 is 0.01 ordinals — chunks under one
2245        // ordinal could never all be non-empty.
2246        let err = resolve(&parse("50%,0.01%,...").unwrap(), 0, 100).unwrap_err();
2247        assert!(err.contains("less than one ordinal"), "diagnostic: {err}");
2248    }
2249
2250    #[test]
2251    fn resolve_pct_boundaries_round_at_cumulative_position() {
2252        // linear:3 over 1000 — per-entry rounding would give
2253        // 333/333/333 and silently drop ordinal 999; the
2254        // boundary rule distributes the slack: 333/334/333,
2255        // covering the extent exactly.
2256        let parts = resolve(&parse("linear:3").unwrap(), 0, 1000).unwrap();
2257        let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2258        assert_eq!(bounds, vec![(0, 333), (333, 667), (667, 1000)]);
2259    }
2260
2261    #[test]
2262    fn resolve_star_split_distributes_rounding_slack() {
2263        // Remainder of 100 into 3: sizes 33/34/33 (rounded
2264        // boundaries), contiguous, exactly covering the extent.
2265        let parts = resolve(&parse("*/3").unwrap(), 0, 100).unwrap();
2266        assert_eq!(parts.len(), 3);
2267        assert_eq!(parts[0].start_ord, 0);
2268        assert_eq!(parts[2].end_ord, 100);
2269        for w in parts.windows(2) {
2270            assert_eq!(w[0].end_ord, w[1].start_ord, "contiguous");
2271        }
2272        let sizes: Vec<u64> = parts.iter().map(|p| p.cardinality()).collect();
2273        assert!(
2274            sizes.iter().all(|s| *s == 33 || *s == 34),
2275            "sizes: {sizes:?}"
2276        );
2277        assert_eq!(sizes.iter().sum::<u64>(), 100);
2278    }
2279
2280    #[test]
2281    fn resolve_star_split_alone_equals_linear_recipe() {
2282        let split = resolve(&parse("*/16").unwrap(), 0, 1600).unwrap();
2283        let linear = resolve(&parse("linear:16").unwrap(), 0, 1600).unwrap();
2284        assert_eq!(split, linear);
2285    }
2286
2287    #[test]
2288    fn resolve_star_split_no_remainder_rejected() {
2289        let err = resolve(&parse("100%,*/4").unwrap(), 0, 1000).unwrap_err();
2290        assert!(err.contains("no remainder"), "diagnostic: {err}");
2291    }
2292
2293    #[test]
2294    fn resolve_star_split_finer_than_remainder_rejected() {
2295        let err = resolve(&parse("90%,*/200").unwrap(), 0, 1000).unwrap_err();
2296        assert!(err.contains("non-empty"), "diagnostic: {err}");
2297    }
2298
2299    #[test]
2300    fn resolve_tail_indices_continue_from_head() {
2301        let parts = resolve(&parse("50%,*/5").unwrap(), 0, 1000).unwrap();
2302        assert_eq!(parts.len(), 6);
2303        for (i, p) in parts.iter().enumerate() {
2304            assert_eq!(p.idx, i as u64);
2305        }
2306    }
2307
2308    #[test]
2309    fn split_evenly_boundaries_monotone_and_exact() {
2310        for (start, end, n) in [
2311            (0u64, 100u64, 7u64),
2312            (5, 5, 1),
2313            (0, 3, 3),
2314            (1000, 10007, 13),
2315        ] {
2316            let chunks = split_evenly(start, end, n);
2317            assert_eq!(chunks.len(), n as usize);
2318            assert_eq!(chunks[0].0, start);
2319            assert_eq!(chunks[n as usize - 1].1, end);
2320            for w in chunks.windows(2) {
2321                assert_eq!(w[0].1, w[1].0);
2322            }
2323            let total: u64 = chunks.iter().map(|(s, e)| e - s).sum();
2324            assert_eq!(total, end - start);
2325        }
2326    }
2327
2328    // ── Whitespace tolerance ───────────────────────────────
2329
2330    #[test]
2331    fn parse_tolerates_whitespace_in_lists() {
2332        let spec = parse(" 2% , 10% , *% ").unwrap();
2333        assert_eq!(
2334            spec,
2335            PartitionSpec::delta_list(vec![Bound::Pct(2.0), Bound::Pct(10.0), Bound::Star])
2336        );
2337    }
2338
2339    // ── Polydat Value round-trip ────────────────────────────────
2340
2341    #[test]
2342    fn partition_roundtrips_through_value_ext() {
2343        let p = Partition {
2344            idx: 2,
2345            count: 4,
2346            start_ord: 100,
2347            end_ord: 500,
2348            start_pct: 10.0,
2349            end_pct: 50.0,
2350            base_extent: 1000,
2351        };
2352        let v = Value::from_partition(p);
2353        let recovered = v.as_partition().expect("downcast");
2354        assert_eq!(recovered.idx, 2);
2355        assert_eq!(recovered.start_ord, 100);
2356        assert_eq!(recovered.end_ord, 500);
2357        assert_eq!(recovered.cardinality(), 400);
2358    }
2359
2360    #[test]
2361    fn partition_spec_roundtrips_through_value_ext() {
2362        let spec = parse("fib:5").unwrap();
2363        let v = Value::from_partition_spec(spec);
2364        let recovered = v.as_partition_spec().expect("downcast");
2365        // Just sanity-check it's a DeltaList with 5 entries
2366        match &recovered.chunking {
2367            Chunking::DeltaList { deltas } => assert_eq!(deltas.len(), 5),
2368            other => panic!("expected DeltaList, got {other:?}"),
2369        }
2370    }
2371
2372    #[test]
2373    fn partition_list_roundtrips_through_value_ext() {
2374        let spec = parse("linear:4").unwrap();
2375        let parts = resolve(&spec, 0, 1000).unwrap();
2376        let v = Value::from_partition_list(parts);
2377        let recovered = v.as_partition_list().expect("downcast");
2378        assert_eq!(recovered.len(), 4);
2379        assert_eq!(recovered.as_slice()[0].start_ord, 0);
2380        assert_eq!(recovered.as_slice()[3].end_ord, 1000);
2381    }
2382
2383    #[test]
2384    fn non_partition_value_downcast_returns_none() {
2385        let v = Value::U64(42);
2386        assert!(v.as_partition().is_none());
2387        assert!(v.as_partition_spec().is_none());
2388        assert!(v.as_partition_list().is_none());
2389    }
2390
2391    #[test]
2392    fn parse_tolerates_whitespace_in_range() {
2393        let spec = parse(" 0 .. 53 % ").unwrap();
2394        assert_eq!(
2395            spec,
2396            PartitionSpec::single_range(Bound::Ord(0), Bound::Pct(53.0))
2397        );
2398    }
2399
2400    // ── Windowed chunking (`in <window>`) ──────────────────
2401
2402    #[test]
2403    fn parse_window_clause() {
2404        let spec = parse("linear:4 in 25%..75%").unwrap();
2405        assert_eq!(spec.window, Some((Bound::Pct(25.0), Bound::Pct(75.0))));
2406        assert_eq!(spec.order, PartitionOrder::Unchanged);
2407        match &spec.chunking {
2408            Chunking::DeltaList { deltas } => assert_eq!(deltas.len(), 4),
2409            other => panic!("expected DeltaList, got {other:?}"),
2410        }
2411    }
2412
2413    #[test]
2414    fn parse_window_requires_range() {
2415        let err = parse("linear:4 in 50%").unwrap_err();
2416        assert!(err.contains("start..end"), "diagnostic: {err}");
2417    }
2418
2419    #[test]
2420    fn parse_window_requires_sized_bounds() {
2421        let err = parse("linear:4 in 0..*").unwrap_err();
2422        assert!(err.contains("sized"), "diagnostic: {err}");
2423    }
2424
2425    #[test]
2426    fn parse_window_clause_position_errors() {
2427        assert!(parse("in 0..50%").unwrap_err().contains("chunking spec"));
2428        assert!(parse("linear:4 in").unwrap_err().contains("window range"));
2429        assert!(
2430            parse("linear:2 in 0..50% in 0..10%")
2431                .unwrap_err()
2432                .contains("at most one")
2433        );
2434    }
2435
2436    #[test]
2437    fn resolve_windowed_chunking_is_window_relative() {
2438        // The chunking resolves against the window's range:
2439        // linear:4 over [20%, 100%) of 1000 → four 200-ordinal
2440        // partitions starting at 200.
2441        let parts = resolve(&parse("linear:4 in 20%..100%").unwrap(), 0, 1000).unwrap();
2442        let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2443        assert_eq!(
2444            bounds,
2445            vec![(200, 400), (400, 600), (600, 800), (800, 1000)]
2446        );
2447    }
2448
2449    #[test]
2450    fn resolve_windowed_form1_composes() {
2451        // `0..50%` of the window [500, 1000) → [500, 750).
2452        let parts = resolve(&parse("0..50% in 50%..100%").unwrap(), 0, 1000).unwrap();
2453        assert_eq!(parts.len(), 1);
2454        assert_eq!((parts[0].start_ord, parts[0].end_ord), (500, 750));
2455    }
2456
2457    #[test]
2458    fn resolve_windowed_tail_tokens() {
2459        // `90%,*/10` inside a window: percentages are relative
2460        // to the window (here [0, 500)), so head = 450 and the
2461        // ten chunks split the remaining 50.
2462        let parts = resolve(&parse("90%,*/10 in 0..50%").unwrap(), 0, 1000).unwrap();
2463        assert_eq!(parts.len(), 11);
2464        assert_eq!((parts[0].start_ord, parts[0].end_ord), (0, 450));
2465        assert_eq!(parts[10].end_ord, 500);
2466        assert_eq!(parts[1].cardinality(), 5);
2467    }
2468
2469    // ── Finite repetition (`<delta>xN`) ────────────────────
2470
2471    #[test]
2472    fn parse_finite_repetition_expands() {
2473        let spec = parse("1%x3").unwrap();
2474        assert_eq!(spec, PartitionSpec::delta_list(vec![Bound::Pct(1.0); 3]));
2475    }
2476
2477    #[test]
2478    fn parse_repetition_zero_rejected() {
2479        let err = parse("1%x0").unwrap_err();
2480        assert!(err.contains(">= 1"), "diagnostic: {err}");
2481    }
2482
2483    #[test]
2484    fn parse_repetition_on_tail_rejected() {
2485        assert!(parse("*x3").is_err());
2486        assert!(parse("...x3").is_err());
2487    }
2488
2489    #[test]
2490    fn resolve_repetition_equals_fill_and_split_at_90_10() {
2491        // The FOURTH spelling of "first 90%, then ten 1% chunks":
2492        // size and count both declared.
2493        let explicit = resolve(&parse("90%,1%,...").unwrap(), 0, 1000).unwrap();
2494        let repeated = resolve(&parse("90%,1%x10").unwrap(), 0, 1000).unwrap();
2495        assert_eq!(repeated, explicit);
2496    }
2497
2498    // ── Gaps (`~<delta>`) ──────────────────────────────────
2499
2500    #[test]
2501    fn parse_gap_entry() {
2502        let spec = parse("10%,~80%,10%").unwrap();
2503        assert_eq!(
2504            spec,
2505            PartitionSpec::delta_list(vec![
2506                Bound::Pct(10.0),
2507                Bound::Gap(Box::new(Bound::Pct(80.0))),
2508                Bound::Pct(10.0),
2509            ])
2510        );
2511    }
2512
2513    #[test]
2514    fn parse_gap_requires_sized_bound() {
2515        let err = parse("10%,~*").unwrap_err();
2516        assert!(err.contains("sized"), "diagnostic: {err}");
2517    }
2518
2519    #[test]
2520    fn parse_gap_repetition_rejected() {
2521        let err = parse("10%,~10%x3").unwrap_err();
2522        assert!(err.contains("size the gap"), "diagnostic: {err}");
2523    }
2524
2525    #[test]
2526    fn parse_all_gaps_rejected() {
2527        let err = parse("~10%,~20%").unwrap_err();
2528        assert!(err.contains("emits no partitions"), "diagnostic: {err}");
2529    }
2530
2531    #[test]
2532    fn parse_fill_after_gap_rejected() {
2533        let err = parse("5%,~5%,...").unwrap_err();
2534        assert!(err.contains("emit nothing"), "diagnostic: {err}");
2535    }
2536
2537    #[test]
2538    fn resolve_gap_consumes_without_emitting() {
2539        let parts = resolve(&parse("10%,~80%,10%").unwrap(), 0, 1000).unwrap();
2540        let bounds: Vec<(u64, u64, u64)> = parts
2541            .iter()
2542            .map(|p| (p.idx, p.start_ord, p.end_ord))
2543            .collect();
2544        // Emitted partitions only; idx counts emitted entries.
2545        assert_eq!(bounds, vec![(0, 0, 100), (1, 900, 1000)]);
2546    }
2547
2548    #[test]
2549    fn resolve_gap_counts_toward_star_remainder() {
2550        // 10% head + 40% gap leaves 50% for the star.
2551        let parts = resolve(&parse("10%,~40%,*").unwrap(), 0, 1000).unwrap();
2552        let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2553        assert_eq!(bounds, vec![(0, 100), (500, 1000)]);
2554    }
2555
2556    // ── Recipe-shaped remainder (`*/recipe`) ───────────────
2557
2558    #[test]
2559    fn parse_star_shaped_recipe() {
2560        let spec = parse("50%,*/ratios:1,3").unwrap();
2561        match &spec.chunking {
2562            Chunking::DeltaList { deltas } => {
2563                assert_eq!(deltas.len(), 2);
2564                match &deltas[1] {
2565                    Bound::StarShaped(w) => {
2566                        assert_eq!(w.len(), 2);
2567                        assert!((w[0] - 25.0).abs() < 1e-9);
2568                        assert!((w[1] - 75.0).abs() < 1e-9);
2569                    }
2570                    other => panic!("expected StarShaped, got {other:?}"),
2571                }
2572            }
2573            other => panic!("expected DeltaList, got {other:?}"),
2574        }
2575    }
2576
2577    #[test]
2578    fn parse_star_linear_rejected_with_canonical_hint() {
2579        let err = parse("90%,*/linear:4").unwrap_err();
2580        assert!(
2581            err.contains("*/4"),
2582            "diagnostic should point at `*/N`: {err}"
2583        );
2584    }
2585
2586    #[test]
2587    fn resolve_star_shaped_divides_remainder_by_weights() {
2588        // Remainder 500, weights 25/75 → [500, 625), [625, 1000).
2589        let parts = resolve(&parse("50%,*/ratios:1,3").unwrap(), 0, 1000).unwrap();
2590        let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2591        assert_eq!(bounds, vec![(0, 500), (500, 625), (625, 1000)]);
2592    }
2593
2594    #[test]
2595    fn resolve_star_shaped_alone_covers_extent() {
2596        let parts = resolve(&parse("*/fib:3").unwrap(), 0, 600).unwrap();
2597        // fib:3 weights [1, 2, 3] → 100/200/300.
2598        let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2599        assert_eq!(bounds, vec![(0, 100), (100, 300), (300, 600)]);
2600    }
2601
2602    #[test]
2603    fn resolve_star_shaped_empty_chunk_rejected() {
2604        // A 0.1%-ish weight of a tiny remainder rounds to zero.
2605        let err = resolve(&parse("90%,*/ratios:1,1000").unwrap(), 0, 100).unwrap_err();
2606        assert!(err.contains("empty partition"), "diagnostic: {err}");
2607    }
2608
2609    // ── Ordering suffix ────────────────────────────────────
2610
2611    #[test]
2612    fn parse_order_suffix() {
2613        assert_eq!(
2614            parse("fib:5 largest_first").unwrap().order,
2615            PartitionOrder::LargestFirst
2616        );
2617        assert_eq!(
2618            parse("fib:5 smallest_first").unwrap().order,
2619            PartitionOrder::SmallestFirst
2620        );
2621        assert_eq!(parse("fib:5 random").unwrap().order, PartitionOrder::Random);
2622        assert_eq!(
2623            parse("fib:5 unchanged").unwrap().order,
2624            PartitionOrder::Unchanged
2625        );
2626        assert_eq!(parse("fib:5").unwrap().order, PartitionOrder::Unchanged);
2627    }
2628
2629    #[test]
2630    fn parse_unknown_order_rejected() {
2631        let err = parse("fib:5 descend").unwrap_err();
2632        assert!(err.contains("unknown order"), "diagnostic: {err}");
2633        assert!(
2634            err.contains("largest_first"),
2635            "diagnostic should list options: {err}"
2636        );
2637    }
2638
2639    #[test]
2640    fn parse_bare_direction_words_rejected_with_axis_hint() {
2641        // `ascending` / `descending` are ambiguous between
2642        // ordinal position and size; the diagnostics teach the
2643        // axis-named spellings.
2644        let err = parse("fib:5 ascending").unwrap_err();
2645        assert!(err.contains("smallest_first"), "diagnostic: {err}");
2646        assert!(
2647            err.contains("SIZE"),
2648            "diagnostic should name the axis: {err}"
2649        );
2650        let err = parse("fib:5 descending").unwrap_err();
2651        assert!(err.contains("largest_first"), "diagnostic: {err}");
2652    }
2653
2654    #[test]
2655    fn resolve_largest_first_sorts_by_cardinality_keeping_idx() {
2656        let parts = resolve(&parse("fib:5 largest_first").unwrap(), 0, 1000).unwrap();
2657        for w in parts.windows(2) {
2658            assert!(w[0].cardinality() >= w[1].cardinality(), "largest first");
2659        }
2660        // fib weights grow, so the largest partition was
2661        // generated last: iteration starts at idx 4.
2662        assert_eq!(parts[0].idx, 4);
2663        assert_eq!(parts[4].idx, 0);
2664    }
2665
2666    #[test]
2667    fn resolve_smallest_first_is_stable_for_equal_sizes() {
2668        // Equal-sized partitions keep generation order.
2669        let parts = resolve(&parse("linear:3 smallest_first").unwrap(), 0, 999).unwrap();
2670        let idxs: Vec<u64> = parts.iter().map(|p| p.idx).collect();
2671        assert_eq!(idxs, vec![0, 1, 2]);
2672    }
2673
2674    #[test]
2675    fn resolve_random_is_deterministic_permutation() {
2676        let a = resolve(&parse("linear:8 random").unwrap(), 0, 800).unwrap();
2677        let b = resolve(&parse("linear:8 random").unwrap(), 0, 800).unwrap();
2678        assert_eq!(a, b, "same spec must shuffle identically");
2679        let mut by_idx = a.clone();
2680        by_idx.sort_by_key(|p| p.idx);
2681        let unchanged = resolve(&parse("linear:8").unwrap(), 0, 800).unwrap();
2682        assert_eq!(
2683            by_idx, unchanged,
2684            "shuffle is a permutation of the same partitions"
2685        );
2686        assert_ne!(
2687            a, unchanged,
2688            "8 elements should not shuffle to identity here"
2689        );
2690    }
2691
2692    #[test]
2693    fn display_round_trips_window_and_order() {
2694        let spec = parse("linear:2 in 0..50% largest_first").unwrap();
2695        let shown = ReflectedValue::display(&spec);
2696        assert!(shown.contains("in 0..50%"), "display: {shown}");
2697        assert!(shown.contains("largest_first"), "display: {shown}");
2698    }
2699
2700    // ── Labelling frames and degenerate extents ────────────
2701
2702    #[test]
2703    fn windowed_partitions_label_against_full_base_frame() {
2704        // The window affects sizing/placement; pct fields and
2705        // base_extent describe the FULL base. This is what lets
2706        // the `over` clause's cross-extent reprojection keep a
2707        // windowed partition's position in the whole domain
2708        // (window-relative pcts would collapse the offset:
2709        // [200, 400) would reproject as if it started at 0).
2710        let parts = resolve(&parse("linear:4 in 20%..100%").unwrap(), 0, 1000).unwrap();
2711        let p = &parts[0];
2712        assert_eq!((p.start_ord, p.end_ord), (200, 400));
2713        assert!(
2714            (p.start_pct - 20.0).abs() < 1e-9,
2715            "start_pct: {}",
2716            p.start_pct
2717        );
2718        assert!((p.end_pct - 40.0).abs() < 1e-9, "end_pct: {}", p.end_pct);
2719        assert_eq!(
2720            p.base_extent, 1000,
2721            "base_extent is the full base, not the window"
2722        );
2723    }
2724
2725    #[test]
2726    fn form1_zero_width_slice_rejected() {
2727        // `0..1%` of a 10-ordinal extent rounds to nothing — an
2728        // operator-explicit slice that would silently run zero
2729        // cycles is an error, not a no-op.
2730        let err = resolve(&parse("0..1%").unwrap(), 0, 10).unwrap_err();
2731        assert!(err.contains("zero ordinals"), "diagnostic: {err}");
2732    }
2733
2734    #[test]
2735    fn delta_list_subordinal_recipe_tails_tolerated() {
2736        // Auto-terminating recipes legitimately produce
2737        // sub-ordinal tail weights on small extents; their
2738        // zero-width entries are correct arithmetic, not an
2739        // error (contrast with the Form 1 check above).
2740        let parts = resolve(&parse("mul:0.5").unwrap(), 0, 100).unwrap();
2741        assert_eq!(
2742            parts.len(),
2743            11,
2744            "term count is weight-driven, not extent-driven"
2745        );
2746        assert_eq!(parts.last().unwrap().end_ord, 100);
2747    }
2748}
2749
2750// ── Host-side cursor narrowing ─────────────────────────────────────────
2751//
2752// A cursor declared `over <expr>` compiles to a raw output carrying the
2753// `over` value plus a set of external-write slots (`<cursor>__cursor` and
2754// its scalar projections) that stay `None` until a host resolves the
2755// value and writes them at scope setup. These helpers are that step, so
2756// every host narrows cursors the same way.
2757
2758/// Resolve the value of an `over` expression into the partitions it
2759/// denotes, against a cursor of `extent` ordinals.
2760///
2761/// A string is parsed as a partition spec and resolved against
2762/// `[0, extent)`. A `Partition` is re-projected onto `extent` from its
2763/// percentage bounds when its base extent differs. A `PartitionSpec` is
2764/// resolved. A `PartitionList` is re-projected element by element.
2765/// `Value::None` yields an empty list. Open-extent cursors reject specs,
2766/// because they have no extent to resolve against.
2767pub fn resolve_over(
2768    value: &Value,
2769    extent: u64,
2770    open_extent: bool,
2771) -> Result<Vec<Partition>, String> {
2772    let reproject = |p: &Partition| -> Partition {
2773        if open_extent || p.base_extent == extent || extent == 0 {
2774            return *p;
2775        }
2776        Partition {
2777            idx: p.idx,
2778            count: p.count,
2779            start_ord: ((p.start_pct / 100.0) * extent as f64).round() as u64,
2780            end_ord: ((p.end_pct / 100.0) * extent as f64).round() as u64,
2781            start_pct: p.start_pct,
2782            end_pct: p.end_pct,
2783            base_extent: extent,
2784        }
2785    };
2786    let reject_open = || {
2787        "an open-extent cursor has no extent to resolve a partition spec against; \
2788         resolve the spec against an explicit extent first and declare the cursor `over p`"
2789            .to_string()
2790    };
2791    match value {
2792        Value::None => Ok(Vec::new()),
2793        Value::Str(s) => {
2794            if open_extent {
2795                return Err(reject_open());
2796            }
2797            resolve(&parse(s.as_ref())?, 0, extent)
2798        }
2799        Value::Ext(b) => {
2800            if let Some(p) = value.as_partition() {
2801                Ok(vec![reproject(p)])
2802            } else if let Some(spec) = value.as_partition_spec() {
2803                if open_extent {
2804                    return Err(reject_open());
2805                }
2806                resolve(spec, 0, extent)
2807            } else if let Some(list) = value.as_partition_list() {
2808                Ok(list.as_slice().iter().map(reproject).collect())
2809            } else {
2810                Err(format!(
2811                    "`over` expression produced an Ext value of type `{}`; expected Partition, PartitionSpec, or PartitionList",
2812                    b.type_name()
2813                ))
2814            }
2815        }
2816        other => Err(format!(
2817            "`over` expression produced an unsupported value; expected a spec string or a partition-typed value, got {other:?}"
2818        )),
2819    }
2820}
2821
2822/// The extent a cursor resolves partitions against: the schema's static
2823/// extent when known, otherwise the difference of its extent outputs
2824/// pulled from `state`.
2825pub fn cursor_extent(
2826    program: &crate::kernel::PolydatProgram,
2827    state: &mut crate::kernel::PolydatState,
2828    schema: &crate::iteration::source::SourceSchema,
2829) -> u64 {
2830    if let Some((start_out, end_out)) = &schema.extent_outputs {
2831        let start = state.pull(program, start_out).as_u64();
2832        let end = state.pull(program, end_out).as_u64();
2833        let extent = end.saturating_sub(start);
2834        return schema.extent_limit.map(|l| extent.min(l)).unwrap_or(extent);
2835    }
2836    schema.extent.unwrap_or(0)
2837}
2838
2839/// Resolve the partitions a cursor's `over` clause denotes: the list the
2840/// compiler resolved at build when the clause and the extent were
2841/// constant, otherwise the raw `over` value pulled from `state` against
2842/// the extent. Returns an empty list for a cursor without an `over`
2843/// clause. The interpreter-state form of [`cursor_over_partitions_on`],
2844/// which does the same on a kernel of any engine.
2845pub fn cursor_over_partitions(
2846    program: &crate::kernel::PolydatProgram,
2847    state: &mut crate::kernel::PolydatState,
2848    schema: &crate::iteration::source::SourceSchema,
2849) -> Result<Vec<Partition>, String> {
2850    if let Some(parts) = &schema.partitions {
2851        return Ok(parts.clone());
2852    }
2853    let Some(raw) = &schema.partition_output else {
2854        return Ok(Vec::new());
2855    };
2856    let value = state.pull(program, raw).clone();
2857    let extent = cursor_extent(program, state, schema);
2858    let open = !matches!(
2859        schema.cursor_kind,
2860        crate::iteration::source::CursorKind::Range
2861    );
2862    resolve_over(&value, extent, open)
2863}
2864
2865/// [`cursor_extent`] through the [`Kernel`](crate::kernel::Kernel)
2866/// trait, for a kernel on any engine.
2867pub fn cursor_extent_on(
2868    kernel: &mut dyn crate::kernel::Kernel,
2869    schema: &crate::iteration::source::SourceSchema,
2870) -> u64 {
2871    if let Some((start_out, end_out)) = &schema.extent_outputs {
2872        let start = kernel.pull(start_out).as_u64();
2873        let end = kernel.pull(end_out).as_u64();
2874        let extent = end.saturating_sub(start);
2875        return schema.extent_limit.map(|l| extent.min(l)).unwrap_or(extent);
2876    }
2877    schema.extent.unwrap_or(0)
2878}
2879
2880/// [`cursor_over_partitions`] through the [`Kernel`](crate::kernel::Kernel)
2881/// trait, for a kernel on any engine.
2882pub fn cursor_over_partitions_on(
2883    kernel: &mut dyn crate::kernel::Kernel,
2884    schema: &crate::iteration::source::SourceSchema,
2885) -> Result<Vec<Partition>, String> {
2886    if let Some(parts) = &schema.partitions {
2887        return Ok(parts.clone());
2888    }
2889    let Some(raw) = &schema.partition_output else {
2890        return Ok(Vec::new());
2891    };
2892    let value = kernel.pull(raw);
2893    let extent = cursor_extent_on(kernel, schema);
2894    let open = !matches!(
2895        schema.cursor_kind,
2896        crate::iteration::source::CursorKind::Range
2897    );
2898    resolve_over(&value, extent, open)
2899}
2900
2901/// The inputs narrowing a cursor to one partition writes, by name: the
2902/// `<cursor>__cursor` slot and its six scalar projections. This is what
2903/// `narrow_cursor` writes on the interpreter and `set_cursor` on every
2904/// compiled kernel.
2905pub fn cursor_slot_writes(cursor_name: &str, partition: &Partition) -> [(String, Value); 7] {
2906    let slot = |suffix: &str| format!("{cursor_name}__cursor{suffix}");
2907    [
2908        (slot(""), Value::from_partition(*partition)),
2909        (slot("__idx"), Value::U64(partition.idx)),
2910        (
2911            slot("__partition_count"),
2912            Value::U64(partition.count.max(1)),
2913        ),
2914        (slot("__start_pct"), Value::F64(partition.start_pct)),
2915        (slot("__end_pct"), Value::F64(partition.end_pct)),
2916        (slot("__start_ordinal"), Value::U64(partition.start_ord)),
2917        (slot("__end_ordinal"), Value::U64(partition.end_ord)),
2918    ]
2919}
2920
2921/// Write one resolved partition into a cursor's `<cursor>__cursor` slot
2922/// and its six scalar projection slots. Slots the program does not
2923/// declare are skipped. The interpreter-state form of `set_cursor` on
2924/// the [`Kernel`](crate::kernel::Kernel) trait, which does the same on
2925/// a kernel of any engine.
2926pub fn narrow_cursor(
2927    program: &crate::kernel::PolydatProgram,
2928    state: &mut crate::kernel::PolydatState,
2929    cursor_name: &str,
2930    partition: &Partition,
2931) {
2932    for (slot, v) in cursor_slot_writes(cursor_name, partition) {
2933        if let Some(idx) = program.find_input(&slot) {
2934            state.set_input(idx, v);
2935        }
2936    }
2937}
2938
2939#[cfg(test)]
2940mod over_tests {
2941    use super::*;
2942
2943    #[test]
2944    fn resolve_over_string_spec_against_extent() {
2945        let parts = resolve_over(&Value::Str("20%,30%,*".into()), 1000, false).unwrap();
2946        let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2947        assert_eq!(bounds, vec![(0, 200), (200, 500), (500, 1000)]);
2948    }
2949
2950    #[test]
2951    fn resolve_over_reprojects_partition_onto_cursor_extent() {
2952        let p = resolve(&parse("50%..100%").unwrap(), 0, 100).unwrap()[0];
2953        let got = resolve_over(&Value::from_partition(p), 1000, false).unwrap();
2954        assert_eq!((got[0].start_ord, got[0].end_ord), (500, 1000));
2955        assert_eq!(got[0].base_extent, 1000);
2956    }
2957
2958    #[test]
2959    fn resolve_over_none_is_empty_and_open_rejects_specs() {
2960        assert!(resolve_over(&Value::None, 10, false).unwrap().is_empty());
2961        assert!(resolve_over(&Value::Str("*/2".into()), 10, true).is_err());
2962    }
2963}