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 — SRD 71.
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 SRD 71:
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        return Ok(vec![b; n as usize]);
634    }
635    Ok(vec![parse_bound(raw)?])
636}
637
638/// Parse the divisor of a star tail (the text after `*/`):
639/// a bare integer count (`*/10`) or a recipe (`*/fib:5`).
640fn parse_star_tail(divisor: &str) -> Result<Bound, String> {
641    // A comma in a non-recipe divisor means entries follow the
642    // star tail — recipes own their comma-separated args, a bare
643    // count doesn't.
644    if !divisor.contains(':') && divisor.contains(',') {
645        let count = divisor.split(',').next().unwrap_or(divisor);
646        return Err(format!(
647            "`*/{count}` consumes the rest of the extent and must be the last \
648             entry in the delta list; got trailing entries after it"
649        ));
650    }
651    if let Some((name, args)) = split_recipe(divisor) {
652        if name == "linear" {
653            return Err(format!(
654                "`*/linear:{args}`: spell an equal-count remainder split as \
655                 `*/{args}` — `*/N` is the canonical form"
656            ));
657        }
658        let weights = normalise_weights(&expand_recipe_weights(name, args)?)?;
659        return Ok(Bound::StarShaped(weights));
660    }
661    if divisor.contains('%') || divisor.contains('.') {
662        return Err(format!(
663            "`*/{divisor}`: the divisor after `*/` is a chunk count and must be a \
664             bare integer (e.g. `*/10` = remainder in 10 equal chunks). For \
665             fixed-size chunks repeated until the extent is used up, spell the \
666             size as a delta followed by the fill token: `{divisor},...`"
667        ));
668    }
669    let n: u64 = divisor.parse().map_err(|_| {
670        format!("invalid remainder split `*/{divisor}`: expected `*/N` with integer N >= 1, or `*/recipe:args`")
671    })?;
672    if n == 0 {
673        return Err("`*/0`: the remainder split count must be >= 1".into());
674    }
675    Ok(Bound::StarSplit(n))
676}
677
678/// If `s` matches `<name>:<args>`, return (name, args). Recipe
679/// names are alphabetic-only (plus `_`) to avoid colliding with
680/// any number form.
681fn split_recipe(s: &str) -> Option<(&str, &str)> {
682    let colon = s.find(':')?;
683    let name = &s[..colon];
684    if name.is_empty() {
685        return None;
686    }
687    if !name.chars().all(|c| c.is_ascii_alphabetic() || c == '_') {
688        return None;
689    }
690    Some((name, &s[colon + 1..]))
691}
692
693/// Find a top-level `..` separator (the Form 1 range marker).
694/// Returns `None` if the input is a single value (no `..`).
695fn split_range(s: &str) -> Option<(&str, &str)> {
696    s.find("..").map(|idx| (&s[..idx], &s[idx + 2..]))
697}
698
699/// Parse a single numeric bound. The form is unambiguous from
700/// the literal's shape; see [`Bound`] for the form-to-variant
701/// mapping.
702fn parse_bound(raw: &str) -> Result<Bound, String> {
703    let s = raw.trim();
704    if s.is_empty() {
705        return Err("empty bound".into());
706    }
707    // Fill token: `...` — repeat the preceding delta.
708    if s == "..." {
709        return Ok(Bound::Fill);
710    }
711    // Star tails (`*/N`, `*/recipe`) are parsed by
712    // [`parse_star_tail`] — the delta-list walk splits them off
713    // before reaching here, so a `*/` reaching this point is a
714    // misplacement (e.g. a Form 1 endpoint) and falls through to
715    // the number-parse error below.
716    // Remainder token: `*` or `*%` (the `%` is decorative).
717    if s == "*" || s == "*%" {
718        return Ok(Bound::Star);
719    }
720    // Percentage form: trailing `%`.
721    if let Some(num) = s.strip_suffix('%') {
722        let value: f64 = num
723            .trim()
724            .parse()
725            .map_err(|_| format!("invalid percentage `{raw}`: expected a number before `%`"))?;
726        if !(0.0..=100.0).contains(&value) {
727            return Err(format!(
728                "percentage `{raw}` out of range — must be in [0%, 100%]"
729            ));
730        }
731        return Ok(Bound::Pct(value));
732    }
733    // Decimal-with-dot: fraction form.
734    if s.contains('.') {
735        let value: f64 = s.parse().map_err(|_| format!("invalid decimal `{raw}`"))?;
736        if !(0.0..=1.0).contains(&value) {
737            return Err(format!(
738                "decimal `{raw}` is ambiguous — fractions must be in [0.0, 1.0]; \
739                 did you mean `{}%` (percentage), `0.0{}` (fraction), or `{}` (literal ordinal)?",
740                value,
741                raw.replace('.', ""),
742                raw.replace('.', ""),
743            ));
744        }
745        return Ok(Bound::Frac(value));
746    }
747    // Bare integer: literal ordinal.
748    let value: u64 = s
749        .parse()
750        .map_err(|_| format!("invalid number `{raw}`: expected an integer ordinal, decimal fraction (0.x), or `N%` percentage"))?;
751    Ok(Bound::Ord(value))
752}
753
754// =========================================================================
755// Pre-baked recipes
756// =========================================================================
757
758/// Dispatch a recipe name + arg string to its raw weight list.
759/// Callers normalise: [`normalise_to_pct`] for a whole-spec
760/// recipe (Form 3), [`normalise_weights`] for a star tail
761/// (`*/recipe`).
762fn expand_recipe_weights(name: &str, args: &str) -> Result<Vec<f64>, String> {
763    let parts: Vec<&str> = args.split(',').map(|s| s.trim()).collect();
764    let weights = match name {
765        "linear" => recipe_linear(&parts)?,
766        "ratios" => recipe_ratios(&parts)?,
767        "mul" => recipe_mul(&parts)?,
768        "bin" => recipe_bin(&parts)?,
769        "fib" => recipe_fib(&parts)?,
770        "ln" => recipe_ln(&parts)?,
771        "geom" => recipe_geom(&parts)?,
772        "zipf" => recipe_zipf(&parts)?,
773        "pareto" => recipe_pareto(&parts)?,
774        "front_heavy" => recipe_front_heavy(&parts)?,
775        "back_heavy" => recipe_back_heavy(&parts)?,
776        _ => {
777            return Err(format!(
778                "unknown recipe `{name}` — supported: linear, ratios, mul, bin, fib, ln, \
779                 geom, zipf, pareto, front_heavy, back_heavy"
780            ));
781        }
782    };
783    Ok(weights)
784}
785
786fn parse_u64_arg(arg: &str, ctx: &str) -> Result<u64, String> {
787    arg.parse()
788        .map_err(|_| format!("invalid integer arg `{arg}` for {ctx}"))
789}
790
791fn parse_f64_arg(arg: &str, ctx: &str) -> Result<f64, String> {
792    arg.parse()
793        .map_err(|_| format!("invalid number arg `{arg}` for {ctx}"))
794}
795
796fn recipe_linear(args: &[&str]) -> Result<Vec<f64>, String> {
797    if args.len() != 1 {
798        return Err(format!(
799            "linear:N expects exactly 1 argument (the partition count); got {}",
800            args.len()
801        ));
802    }
803    let n = parse_u64_arg(args[0], "linear")?;
804    if n == 0 {
805        return Err("linear:N requires N >= 1".into());
806    }
807    Ok(vec![1.0; n as usize])
808}
809
810fn recipe_ratios(args: &[&str]) -> Result<Vec<f64>, String> {
811    if args.is_empty() {
812        return Err("ratios:a,b,c,... requires at least one weight".into());
813    }
814    args.iter().map(|a| parse_f64_arg(a, "ratios")).collect()
815}
816
817fn recipe_mul(args: &[&str]) -> Result<Vec<f64>, String> {
818    let (start, ratio) = match args.len() {
819        1 => (1.0, parse_f64_arg(args[0], "mul")?),
820        2 => (
821            parse_f64_arg(args[0], "mul")?,
822            parse_f64_arg(args[1], "mul")?,
823        ),
824        n => {
825            return Err(format!(
826                "mul:R or mul:S,R expects 1 or 2 arguments; got {n}"
827            ));
828        }
829    };
830    if start <= 0.0 {
831        return Err(format!("mul:S,R requires S > 0; got {start}"));
832    }
833    if ratio <= 0.0 {
834        return Err(format!("mul:R requires R > 0; got {ratio}"));
835    }
836    // Two termination rules, whichever fires first:
837    //  - decay case (R < 1): stop when current < start * 0.001 — the
838    //    new term contributes less than 0.1% of the leading partition.
839    //  - growth case (R >= 1): hard term cap. Without an explicit
840    //    count the natural choice is the term where the geometric
841    //    growth has covered ~3 orders of magnitude; that's about
842    //    log_R(1000) terms. Use `geom:N,R` instead when you want a
843    //    specific term count.
844    const HARD_CAP: usize = 64;
845    let mut weights = Vec::with_capacity(HARD_CAP);
846    let mut current = start;
847    for _ in 0..HARD_CAP {
848        if !current.is_finite() || current <= 0.0 {
849            break;
850        }
851        weights.push(current);
852        if ratio < 1.0 && current < start * 0.001 {
853            break;
854        }
855        current *= ratio;
856        if ratio >= 1.0 && current >= start * 1000.0 {
857            // Growth-case stop: include the next term so the
858            // last partition is the dominant one.
859            if current.is_finite() {
860                weights.push(current);
861            }
862            break;
863        }
864    }
865    if weights.is_empty() {
866        return Err(format!(
867            "mul:{start},{ratio} produced no terms — pick a larger start"
868        ));
869    }
870    Ok(weights)
871}
872
873fn recipe_bin(args: &[&str]) -> Result<Vec<f64>, String> {
874    if args.len() != 1 {
875        return Err(format!(
876            "bin:N expects exactly 1 argument (the term count); got {}",
877            args.len()
878        ));
879    }
880    let n = parse_u64_arg(args[0], "bin")?;
881    if n == 0 {
882        return Err("bin:N requires N >= 1".into());
883    }
884    // Coefficients of (1+x)^(N-1): C(N-1, k) for k = 0..N-1.
885    let degree = n - 1;
886    let mut coeffs = vec![1.0f64; n as usize];
887    for k in 1..=degree {
888        coeffs[k as usize] = coeffs[(k - 1) as usize] * ((degree - k + 1) as f64) / (k as f64);
889    }
890    Ok(coeffs)
891}
892
893fn recipe_fib(args: &[&str]) -> Result<Vec<f64>, String> {
894    if args.len() != 1 {
895        return Err(format!(
896            "fib:N expects exactly 1 argument (the term count); got {}",
897            args.len()
898        ));
899    }
900    let n = parse_u64_arg(args[0], "fib")?;
901    if n == 0 {
902        return Err("fib:N requires N >= 1".into());
903    }
904    // Skip the redundant leading `1, 1` — use the distinct
905    // Fibonacci values starting at 1: 1, 2, 3, 5, 8, 13, ...
906    let mut weights = Vec::with_capacity(n as usize);
907    let (mut a, mut b) = (1u64, 2u64);
908    for _ in 0..n {
909        weights.push(a as f64);
910        let next = a.saturating_add(b);
911        a = b;
912        b = next;
913    }
914    Ok(weights)
915}
916
917fn recipe_ln(args: &[&str]) -> Result<Vec<f64>, String> {
918    if args.len() != 1 {
919        return Err(format!(
920            "ln:N expects exactly 1 argument (the term count); got {}",
921            args.len()
922        ));
923    }
924    let n = parse_u64_arg(args[0], "ln")?;
925    if n == 0 {
926        return Err("ln:N requires N >= 1".into());
927    }
928    Ok((1..=n).map(|i| (1.0 + i as f64).ln()).collect())
929}
930
931fn recipe_geom(args: &[&str]) -> Result<Vec<f64>, String> {
932    if args.len() != 2 {
933        return Err(format!(
934            "geom:N,R expects exactly 2 arguments; got {}",
935            args.len()
936        ));
937    }
938    let n = parse_u64_arg(args[0], "geom")?;
939    let r = parse_f64_arg(args[1], "geom")?;
940    if n == 0 {
941        return Err("geom:N,R requires N >= 1".into());
942    }
943    if r <= 0.0 {
944        return Err(format!("geom:N,R requires R > 0; got {r}"));
945    }
946    let mut weights = Vec::with_capacity(n as usize);
947    let mut current = 1.0;
948    for _ in 0..n {
949        weights.push(current);
950        current *= r;
951    }
952    Ok(weights)
953}
954
955fn recipe_zipf(args: &[&str]) -> Result<Vec<f64>, String> {
956    if args.len() != 2 {
957        return Err(format!(
958            "zipf:s,N expects exactly 2 arguments; got {}",
959            args.len()
960        ));
961    }
962    let s = parse_f64_arg(args[0], "zipf")?;
963    let n = parse_u64_arg(args[1], "zipf")?;
964    if s <= 0.0 {
965        return Err(format!("zipf:s,N requires s > 0; got {s}"));
966    }
967    if n == 0 {
968        return Err("zipf:s,N requires N >= 1".into());
969    }
970    Ok((1..=n).map(|i| 1.0 / (i as f64).powf(s)).collect())
971}
972
973fn recipe_pareto(args: &[&str]) -> Result<Vec<f64>, String> {
974    if args.len() != 2 {
975        return Err(format!(
976            "pareto:alpha,N expects exactly 2 arguments; got {}",
977            args.len()
978        ));
979    }
980    let alpha = parse_f64_arg(args[0], "pareto")?;
981    let n = parse_u64_arg(args[1], "pareto")?;
982    if alpha <= 0.0 {
983        return Err(format!("pareto:alpha,N requires alpha > 0; got {alpha}"));
984    }
985    if n == 0 {
986        return Err("pareto:alpha,N requires N >= 1".into());
987    }
988    Ok((1..=n).map(|i| (1.0 / i as f64).powf(alpha)).collect())
989}
990
991fn recipe_front_heavy(args: &[&str]) -> Result<Vec<f64>, String> {
992    if args.len() != 1 {
993        return Err(format!(
994            "front_heavy:N expects exactly 1 argument; got {}",
995            args.len()
996        ));
997    }
998    let n = parse_u64_arg(args[0], "front_heavy")?;
999    if n == 0 {
1000        return Err("front_heavy:N requires N >= 1".into());
1001    }
1002    Ok((1..=n).rev().map(|i| i as f64).collect())
1003}
1004
1005fn recipe_back_heavy(args: &[&str]) -> Result<Vec<f64>, String> {
1006    if args.len() != 1 {
1007        return Err(format!(
1008            "back_heavy:N expects exactly 1 argument; got {}",
1009            args.len()
1010        ));
1011    }
1012    let n = parse_u64_arg(args[0], "back_heavy")?;
1013    if n == 0 {
1014        return Err("back_heavy:N requires N >= 1".into());
1015    }
1016    Ok((1..=n).map(|i| i as f64).collect())
1017}
1018
1019/// Normalise raw recipe weights so they sum to 100. Weights
1020/// must be non-negative and have a positive sum.
1021fn normalise_weights(weights: &[f64]) -> Result<Vec<f64>, String> {
1022    if weights.iter().any(|w| !w.is_finite() || *w < 0.0) {
1023        return Err("recipe produced non-finite or negative weights".into());
1024    }
1025    let sum: f64 = weights.iter().sum();
1026    if sum <= 0.0 {
1027        return Err("recipe produced zero total weight".into());
1028    }
1029    Ok(weights.iter().map(|w| w / sum * 100.0).collect())
1030}
1031
1032/// Normalise raw recipe weights to percentage deltas summing
1033/// to 100%.
1034fn normalise_to_pct(weights: &[f64]) -> Result<Vec<Bound>, String> {
1035    Ok(normalise_weights(weights)?
1036        .into_iter()
1037        .map(Bound::Pct)
1038        .collect())
1039}
1040
1041// =========================================================================
1042// Resolution
1043// =========================================================================
1044
1045/// Resolve a [`PartitionSpec`] against a cursor's base extent
1046/// `[base_start, base_end)`, producing a list of concrete
1047/// [`Partition`]s with absolute ordinals.
1048///
1049/// An `in` window narrows the domain first: the window's range
1050/// resolves against the full extent, then the chunking resolves
1051/// against the window (percentages inside the chunking are
1052/// window-relative, and the resulting partitions' `base_extent`
1053/// / pct fields are window-based).
1054///
1055/// For [`Chunking::SingleRange`] the result is always a
1056/// 1-element vector.
1057///
1058/// For [`Chunking::DeltaList`] the deltas are walked
1059/// left-to-right. Gap entries consume extent without emitting.
1060/// Tail tokens consume the unallocated remainder: `Bound::Star`
1061/// absorbs it as one partition, `Bound::Fill` repeats the
1062/// preceding delta until the domain end (final chunk truncated,
1063/// never dropped), `Bound::StarSplit(n)` divides it into `n`
1064/// near-equal partitions, and `Bound::StarShaped(weights)`
1065/// divides it by recipe weights. A sized-delta sum exceeding
1066/// the extent is a hard error.
1067///
1068/// Finally, the spec's [`PartitionOrder`] reorders the list for
1069/// iteration; `idx` keeps identifying the generation position.
1070///
1071/// **Frames:** the window affects *sizing and placement* only —
1072/// percentages inside the chunking are window-relative when
1073/// computing boundaries. The resulting partitions' `start_pct` /
1074/// `end_pct` / `base_extent` are always labelled against the
1075/// **full base frame**, so a windowed partition re-projected
1076/// onto another extent (the `over` clause's cross-extent
1077/// contract) keeps its position in the whole domain instead of
1078/// collapsing the window offset.
1079pub fn resolve(
1080    spec: &PartitionSpec,
1081    base_start: u64,
1082    base_end: u64,
1083) -> Result<Vec<Partition>, String> {
1084    if base_end < base_start {
1085        return Err(format!(
1086            "resolve: base_end ({base_end}) < base_start ({base_start})"
1087        ));
1088    }
1089    let base_extent = base_end - base_start;
1090    // Window: narrow the domain the chunking applies to.
1091    let (dom_start, dom_end) = match &spec.window {
1092        None => (base_start, base_end),
1093        Some((ws, we)) => {
1094            let s = ws
1095                .resolve_against(base_start, base_end)
1096                .expect("window bounds are sized (checked at parse time)");
1097            let e = we
1098                .resolve_against(base_start, base_end)
1099                .expect("window bounds are sized (checked at parse time)");
1100            if e < s {
1101                return Err(format!(
1102                    "window `in {ws}..{we}` is empty or reversed against \
1103                     base=[{base_start}..{base_end}): start={s}, end={e}"
1104                ));
1105            }
1106            (s, e)
1107        }
1108    };
1109    let dom_extent = dom_end - dom_start;
1110    // Labelling frame: pct fields and base_extent always
1111    // describe the full base, regardless of the window.
1112    let frame = Frame {
1113        base_start,
1114        base_extent,
1115    };
1116    let mut partitions = match &spec.chunking {
1117        Chunking::SingleRange { start, end } => {
1118            let start_ord = start
1119                .resolve_against(dom_start, dom_end)
1120                .expect("tail tokens not allowed in SingleRange (checked at parse time)");
1121            let end_ord = end
1122                .resolve_against(dom_start, dom_end)
1123                .expect("tail tokens not allowed in SingleRange (checked at parse time)");
1124            if end_ord < start_ord {
1125                return Err(format!(
1126                    "resolved range is empty or reversed: start={start_ord}, end={end_ord} \
1127                     (spec start={start}, end={end}, base=[{dom_start}..{dom_end}))"
1128                ));
1129            }
1130            // A Form 1 range is an operator-explicit slice; one
1131            // that rounds to zero ordinals would silently run
1132            // nothing — the "why did this do nothing" trap.
1133            // (Delta lists are NOT held to this: auto-
1134            // terminating recipes like `mul:0.5` legitimately
1135            // produce sub-ordinal tail weights on small extents,
1136            // and their zero-width entries iterate zero cycles
1137            // by correct arithmetic. The tail tokens carry their
1138            // own non-empty guards.)
1139            if start_ord == end_ord {
1140                return Err(format!(
1141                    "range `{start}..{end}` resolves to zero ordinals \
1142                     ([{start_ord}..{end_ord}) against base=[{dom_start}..{dom_end})) — \
1143                     the slice rounds to nothing at this extent; widen the range \
1144                     or use a larger extent"
1145                ));
1146            }
1147            vec![frame.partition(0, start_ord, end_ord)]
1148        }
1149        Chunking::DeltaList { deltas } => {
1150            resolve_delta_list(deltas, dom_start, dom_end, dom_extent, frame)?
1151        }
1152    };
1153    // Patch the sibling count now that the list is complete.
1154    let count = partitions.len() as u64;
1155    for p in &mut partitions {
1156        p.count = count;
1157    }
1158    apply_order(&mut partitions, spec);
1159    Ok(partitions)
1160}
1161
1162/// The labelling frame for resolved partitions: pct fields and
1163/// `base_extent` always describe the cursor's full base, even
1164/// when a window narrows where the chunking lands.
1165#[derive(Clone, Copy)]
1166struct Frame {
1167    base_start: u64,
1168    base_extent: u64,
1169}
1170
1171impl Frame {
1172    fn partition(&self, idx: u64, start_ord: u64, end_ord: u64) -> Partition {
1173        // `count` is patched in one post-pass once the full list
1174        // is built (see `resolve`).
1175        Partition {
1176            count: 0,
1177            idx,
1178            start_ord,
1179            end_ord,
1180            start_pct: pct_of(start_ord, self.base_start, self.base_extent),
1181            end_pct: pct_of(end_ord, self.base_start, self.base_extent),
1182            base_extent: self.base_extent,
1183        }
1184    }
1185}
1186
1187/// Reorder a resolved partition list per the spec's order
1188/// keyword. `SmallestFirst` / `LargestFirst` sort by
1189/// **cardinality** (stable — equal-sized partitions keep their
1190/// generation order); `Random` is a deterministic Fisher–Yates
1191/// shuffle seeded from the spec text, so the same spec yields
1192/// the same order on every run. `idx` values are not
1193/// reassigned — they keep identifying the generation position.
1194fn apply_order(partitions: &mut [Partition], spec: &PartitionSpec) {
1195    match spec.order {
1196        PartitionOrder::Unchanged => {}
1197        PartitionOrder::SmallestFirst => {
1198            partitions.sort_by_key(|p| p.cardinality());
1199        }
1200        PartitionOrder::LargestFirst => {
1201            partitions.sort_by_key(|p| std::cmp::Reverse(p.cardinality()));
1202        }
1203        PartitionOrder::Random => {
1204            let mut state = xxhash_rust::xxh3::xxh3_64(format!("{spec:?}").as_bytes());
1205            for i in (1..partitions.len()).rev() {
1206                let j = (splitmix64(&mut state) % (i as u64 + 1)) as usize;
1207                partitions.swap(i, j);
1208            }
1209        }
1210    }
1211}
1212
1213/// SplitMix64 step — the deterministic stream behind
1214/// [`PartitionOrder::Random`].
1215fn splitmix64(state: &mut u64) -> u64 {
1216    *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
1217    let mut z = *state;
1218    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1219    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1220    z ^ (z >> 31)
1221}
1222
1223fn resolve_delta_list(
1224    deltas: &[Bound],
1225    dom_start: u64,
1226    dom_end: u64,
1227    extent: u64,
1228    frame: Frame,
1229) -> Result<Vec<Partition>, String> {
1230    // One boundary rule everywhere: every partition boundary is
1231    // the *exact* cumulative position, rounded once. Sizes are
1232    // boundary differences. This keeps rounding slack from
1233    // accumulating across entries (`linear:3` over 1000 yields
1234    // 333/334/333 covering the extent exactly, not 333/333/333
1235    // with a silently dropped ordinal) and matches Form 1's
1236    // position-rounding and `split_evenly`'s boundary math.
1237    //
1238    // `extent` here is the (possibly windowed) domain the deltas
1239    // size against; `frame` is the full-base labelling frame.
1240    let non_tail_exact: f64 = deltas
1241        .iter()
1242        .filter(|b| !b.is_tail())
1243        .map(|b| delta_exact_ordinals(b, extent))
1244        .sum();
1245    // Float-noise tolerance: a list like `90%,1%,…(×10)` may sum
1246    // to 100% plus epsilon; only reject genuine overshoot.
1247    let tolerance = 1e-6 * (extent as f64).max(1.0);
1248    if non_tail_exact > extent as f64 + tolerance {
1249        return Err(format!(
1250            "delta list sums to {} ordinals, exceeding the cursor's extent {extent}; \
1251             trim the list or use a `*` remainder to absorb the overflow",
1252            non_tail_exact.round() as u64
1253        ));
1254    }
1255    let mut partitions: Vec<Partition> = Vec::with_capacity(deltas.len());
1256    let mut cursor = dom_start;
1257    // Exact running position, in ordinals relative to dom_start.
1258    let mut exact_pos = 0.0f64;
1259    let push = |partitions: &mut Vec<Partition>, start: u64, end: u64| {
1260        let idx = partitions.len() as u64;
1261        partitions.push(frame.partition(idx, start, end));
1262    };
1263    let boundary = |exact_pos: f64| -> u64 { (dom_start + exact_pos.round() as u64).min(dom_end) };
1264    for (i, delta) in deltas.iter().enumerate() {
1265        match delta {
1266            Bound::Star => {
1267                // Absorb exactly what the sized deltas leave.
1268                exact_pos += extent as f64 - non_tail_exact;
1269                let next = boundary(exact_pos);
1270                push(&mut partitions, cursor, next);
1271                cursor = next;
1272            }
1273            Bound::Fill => {
1274                // Parse guarantees Fill is last with a sized
1275                // delta before it; repeat that delta's exact
1276                // size until the extent is used up. The final
1277                // chunk truncates at `dom_end` — emitted
1278                // short, never dropped.
1279                let chunk = delta_exact_ordinals(&deltas[i - 1], extent);
1280                if chunk < 1.0 {
1281                    return Err(format!(
1282                        "fill token `...` would repeat a delta of less than one \
1283                         ordinal (`{}` resolves to {chunk:.3} ordinals against \
1284                         extent {extent})",
1285                        deltas[i - 1]
1286                    ));
1287                }
1288                while cursor < dom_end {
1289                    exact_pos += chunk;
1290                    let next = boundary(exact_pos);
1291                    push(&mut partitions, cursor, next);
1292                    cursor = next;
1293                }
1294            }
1295            Bound::StarSplit(n) => {
1296                // Parse guarantees StarSplit is last; what's
1297                // left of the extent is split into n near-equal
1298                // partitions.
1299                let remainder = dom_end - cursor;
1300                if remainder == 0 {
1301                    return Err(format!(
1302                        "`*/{n}` has no remainder to divide — the preceding deltas \
1303                         already cover the extent {extent}"
1304                    ));
1305                }
1306                if *n > remainder {
1307                    return Err(format!(
1308                        "`*/{n}` cannot divide a remainder of {remainder} ordinals \
1309                         into {n} non-empty partitions"
1310                    ));
1311                }
1312                for (s, e) in split_evenly(cursor, dom_end, *n) {
1313                    push(&mut partitions, s, e);
1314                }
1315                cursor = dom_end;
1316            }
1317            Bound::StarShaped(weights) => {
1318                // Parse guarantees StarShaped is last; what's
1319                // left of the extent is divided by the recipe's
1320                // normalised weights (cumulative-position
1321                // rounding, same rule as everything else).
1322                let remainder = dom_end - cursor;
1323                if remainder == 0 {
1324                    return Err(format!(
1325                        "`*/<recipe>` has no remainder to divide — the preceding \
1326                         deltas already cover the extent {extent}"
1327                    ));
1328                }
1329                let start = cursor;
1330                let mut cum = 0.0f64;
1331                for w in weights {
1332                    cum += w;
1333                    let next =
1334                        (start + ((cum / 100.0) * remainder as f64).round() as u64).min(dom_end);
1335                    if next == cursor {
1336                        return Err(format!(
1337                            "`*/<recipe>` produces an empty partition — weight \
1338                             {w:.3}% of a {remainder}-ordinal remainder rounds to \
1339                             zero ordinals; use fewer/coarser weights or a larger \
1340                             remainder"
1341                        ));
1342                    }
1343                    push(&mut partitions, cursor, next);
1344                    cursor = next;
1345                }
1346                exact_pos += remainder as f64;
1347            }
1348            Bound::Gap(inner) => {
1349                // Consume the gap's extent without emitting a
1350                // partition. The walk stays contiguous; the
1351                // emitted set skips this range.
1352                exact_pos += delta_exact_ordinals(inner, extent);
1353                cursor = boundary(exact_pos);
1354            }
1355            other => {
1356                exact_pos += delta_exact_ordinals(other, extent);
1357                let next = boundary(exact_pos);
1358                push(&mut partitions, cursor, next);
1359                cursor = next;
1360            }
1361        }
1362    }
1363    // Trailing-gap policy: deltas summing to less than the
1364    // extent (without a tail token) drop the gap. `cursor` may
1365    // end short of `dom_end` — that's intentional, not an error.
1366    debug_assert!(cursor <= dom_end);
1367    Ok(partitions)
1368}
1369
1370/// Convert a delta `Bound` to its *exact* size in ordinals
1371/// against an extent (unrounded — boundaries round once, at the
1372/// cumulative position). A gap's size is its wrapped bound's.
1373/// Tail tokens are the caller's responsibility (their sizes
1374/// depend on the sized deltas).
1375fn delta_exact_ordinals(b: &Bound, extent: u64) -> f64 {
1376    match b {
1377        Bound::Pct(p) => (p / 100.0) * extent as f64,
1378        Bound::Frac(f) => f * extent as f64,
1379        Bound::Ord(o) => *o as f64,
1380        Bound::Gap(inner) => delta_exact_ordinals(inner, extent),
1381        Bound::Star | Bound::Fill | Bound::StarSplit(_) | Bound::StarShaped(_) => {
1382            unreachable!("tail tokens handled separately")
1383        }
1384    }
1385}
1386
1387/// Split a resolved [`Partition`] into `n` contiguous
1388/// sub-partitions whose sizes differ by at most one ordinal —
1389/// the value-level form of the `*/N` spec token (identical
1390/// boundary math via [`split_evenly`]). Indices restart at 0,
1391/// `count` is `n`, `base_extent` propagates, and the pct fields
1392/// interpolate the parent's span.
1393///
1394/// Errors when `n` is 0 or exceeds the partition's cardinality
1395/// (every sub-partition must be non-empty). This is the shared
1396/// engine behind the `subdivide(p, n)` node in `polydat-nodes` (which
1397/// panics per node convention) and the comprehension-source
1398/// form (`for: "inner in subdivide(outer, n)"`, which surfaces
1399/// the error as a clause diagnostic).
1400pub fn subdivide_partition(p: &Partition, n: u64) -> Result<Vec<Partition>, String> {
1401    let card = p.cardinality();
1402    if n == 0 {
1403        return Err("subdivide(p, 0): the sub-partition count must be >= 1".into());
1404    }
1405    if n > card {
1406        return Err(format!(
1407            "subdivide(p, {n}): cannot divide partition #{} of {card} ordinals \
1408             into {n} non-empty sub-partitions",
1409            p.idx
1410        ));
1411    }
1412    let pct_at = |ord: u64| -> f64 {
1413        p.start_pct + (ord - p.start_ord) as f64 / card as f64 * (p.end_pct - p.start_pct)
1414    };
1415    Ok(split_evenly(p.start_ord, p.end_ord, n)
1416        .into_iter()
1417        .enumerate()
1418        .map(|(i, (start_ord, end_ord))| Partition {
1419            idx: i as u64,
1420            count: n,
1421            start_ord,
1422            end_ord,
1423            start_pct: pct_at(start_ord),
1424            end_pct: pct_at(end_ord),
1425            base_extent: p.base_extent,
1426        })
1427        .collect())
1428}
1429
1430/// Split `[start_ord, end_ord)` into `n` contiguous half-open
1431/// chunks whose sizes differ by at most one ordinal. Boundary
1432/// `i` sits at `start_ord + round(i * span / n)`, so the
1433/// rounding slack is distributed across the chunks rather than
1434/// accumulating in the last one. `n` must be >= 1.
1435///
1436/// Shared between the `*/N` spec tail token and the
1437/// `subdivide(p, n)` node in `polydat-nodes` so both produce identical
1438/// boundaries.
1439pub fn split_evenly(start_ord: u64, end_ord: u64, n: u64) -> Vec<(u64, u64)> {
1440    debug_assert!(n >= 1, "split_evenly requires n >= 1");
1441    debug_assert!(end_ord >= start_ord);
1442    let span = (end_ord - start_ord) as u128;
1443    let n_wide = n as u128;
1444    let boundary =
1445        |i: u64| -> u64 { start_ord + ((i as u128 * span + n_wide / 2) / n_wide) as u64 };
1446    (0..n).map(|i| (boundary(i), boundary(i + 1))).collect()
1447}
1448
1449#[inline]
1450fn pct_of(ordinal: u64, base_start: u64, extent: u64) -> f64 {
1451    if extent == 0 {
1452        0.0
1453    } else {
1454        (ordinal - base_start) as f64 * 100.0 / extent as f64
1455    }
1456}
1457
1458// =========================================================================
1459// Polydat Value integration
1460// =========================================================================
1461//
1462// Partition and PartitionSpec carry through Polydat wires as
1463// `Value::Ext(Box<dyn ReflectedValue>)` rather than dedicated
1464// enum variants. This avoids sweeping every Value-match site in
1465// the codebase. Stdlib node functions that consume partitions
1466// downcast via [`Value::as_partition`] / [`Value::as_partition_spec`]
1467// at their entry points.
1468
1469impl ReflectedValue for Partition {
1470    fn type_name(&self) -> &str {
1471        "Partition"
1472    }
1473
1474    fn display(&self) -> String {
1475        format!(
1476            "Partition({}/{} [{}..{}) [{:.2}%..{:.2}%))",
1477            self.idx, self.count, self.start_ord, self.end_ord, self.start_pct, self.end_pct,
1478        )
1479    }
1480
1481    fn to_json_value(&self) -> serde_json::Value {
1482        serde_json::json!({
1483            "idx":         self.idx,
1484            "count":       self.count,
1485            "start_ord":   self.start_ord,
1486            "end_ord":     self.end_ord,
1487            "start_pct":   self.start_pct,
1488            "end_pct":     self.end_pct,
1489            "base_extent": self.base_extent,
1490            "cardinality": self.cardinality(),
1491        })
1492    }
1493
1494    fn as_any(&self) -> &dyn std::any::Any {
1495        self
1496    }
1497
1498    fn clone_reflected(&self) -> Box<dyn ReflectedValue> {
1499        Box::new(*self)
1500    }
1501}
1502
1503impl ReflectedValue for PartitionSpec {
1504    fn type_name(&self) -> &str {
1505        "PartitionSpec"
1506    }
1507
1508    fn display(&self) -> String {
1509        let chunking = match &self.chunking {
1510            Chunking::SingleRange { start, end } => format!("{start}..{end}"),
1511            Chunking::DeltaList { deltas } => {
1512                let parts: Vec<String> = deltas.iter().map(|b| b.to_string()).collect();
1513                parts.join(",")
1514            }
1515        };
1516        let window = match &self.window {
1517            Some((s, e)) => format!(" in {s}..{e}"),
1518            None => String::new(),
1519        };
1520        let order = match self.order {
1521            PartitionOrder::Unchanged => String::new(),
1522            o => format!(" {o}"),
1523        };
1524        format!("PartitionSpec({chunking}{window}{order})")
1525    }
1526
1527    fn to_json_value(&self) -> serde_json::Value {
1528        serde_json::Value::String(self.display())
1529    }
1530
1531    fn as_any(&self) -> &dyn std::any::Any {
1532        self
1533    }
1534
1535    fn clone_reflected(&self) -> Box<dyn ReflectedValue> {
1536        Box::new(self.clone())
1537    }
1538}
1539
1540/// A list of resolved partitions carried as a single Polydat value.
1541///
1542/// Needed because [`Value::Ext`] holds one [`ReflectedValue`] —
1543/// to flow a `Vec<Partition>` on a single wire we wrap it once
1544/// here. Backed by `Arc` so cloning is one atomic increment.
1545#[derive(Debug, Clone)]
1546pub struct PartitionList(pub Arc<Vec<Partition>>);
1547
1548impl PartitionList {
1549    /// A list of the given partitions.
1550    pub fn new(partitions: Vec<Partition>) -> Self {
1551        Self(Arc::new(partitions))
1552    }
1553
1554    /// Number of partitions in the list.
1555    pub fn len(&self) -> usize {
1556        self.0.len()
1557    }
1558
1559    /// True if the list is empty.
1560    pub fn is_empty(&self) -> bool {
1561        self.0.is_empty()
1562    }
1563
1564    /// Borrow the underlying slice for iteration.
1565    pub fn as_slice(&self) -> &[Partition] {
1566        &self.0
1567    }
1568}
1569
1570impl ReflectedValue for PartitionList {
1571    fn type_name(&self) -> &str {
1572        "PartitionList"
1573    }
1574
1575    fn display(&self) -> String {
1576        let parts: Vec<String> = self
1577            .0
1578            .iter()
1579            .map(|p| format!("[{}..{})", p.start_ord, p.end_ord))
1580            .collect();
1581        format!("PartitionList[{}]={}", self.0.len(), parts.join(","))
1582    }
1583
1584    fn to_json_value(&self) -> serde_json::Value {
1585        serde_json::Value::Array(self.0.iter().map(|p| p.to_json_value()).collect())
1586    }
1587
1588    fn as_any(&self) -> &dyn std::any::Any {
1589        self
1590    }
1591
1592    fn clone_reflected(&self) -> Box<dyn ReflectedValue> {
1593        Box::new(self.clone())
1594    }
1595}
1596
1597/// Convenience constructors and downcasters on [`Value`] for
1598/// partition-typed wires. Use these at node entry / exit to
1599/// avoid `Value::Ext(Box::new(...))` boilerplate.
1600impl Value {
1601    /// Wrap a [`Partition`] as a Polydat `Value::Ext`.
1602    pub fn from_partition(p: Partition) -> Self {
1603        Value::Ext(Box::new(p))
1604    }
1605
1606    /// Wrap a [`PartitionSpec`] as a Polydat `Value::Ext`.
1607    pub fn from_partition_spec(s: PartitionSpec) -> Self {
1608        Value::Ext(Box::new(s))
1609    }
1610
1611    /// Wrap a `Vec<Partition>` as a Polydat `Value::Ext` via
1612    /// [`PartitionList`]. Use this when a wire needs to carry
1613    /// the whole resolved list (e.g. the `<param>.partitions`
1614    /// projection).
1615    pub fn from_partition_list(parts: Vec<Partition>) -> Self {
1616        Value::Ext(Box::new(PartitionList::new(parts)))
1617    }
1618
1619    /// Downcast to a [`Partition`] reference. Returns `None` if
1620    /// the value isn't a partition.
1621    pub fn as_partition(&self) -> Option<&Partition> {
1622        match self {
1623            Value::Ext(b) => b.as_any().downcast_ref::<Partition>(),
1624            _ => None,
1625        }
1626    }
1627
1628    /// Downcast to a [`PartitionSpec`] reference. Returns `None`
1629    /// if the value isn't a spec.
1630    pub fn as_partition_spec(&self) -> Option<&PartitionSpec> {
1631        match self {
1632            Value::Ext(b) => b.as_any().downcast_ref::<PartitionSpec>(),
1633            _ => None,
1634        }
1635    }
1636
1637    /// Downcast to a [`PartitionList`] reference. Returns `None`
1638    /// if the value isn't a partition list.
1639    pub fn as_partition_list(&self) -> Option<&PartitionList> {
1640        match self {
1641            Value::Ext(b) => b.as_any().downcast_ref::<PartitionList>(),
1642            _ => None,
1643        }
1644    }
1645}
1646
1647// =========================================================================
1648// Tests
1649// =========================================================================
1650
1651#[cfg(test)]
1652mod tests {
1653    use super::*;
1654
1655    // ── Number-form parsing ────────────────────────────────
1656
1657    #[test]
1658    fn parse_bound_percentage() {
1659        assert_eq!(parse_bound("53%").unwrap(), Bound::Pct(53.0));
1660        assert_eq!(parse_bound("0%").unwrap(), Bound::Pct(0.0));
1661        assert_eq!(parse_bound("100%").unwrap(), Bound::Pct(100.0));
1662        assert_eq!(parse_bound("0.5%").unwrap(), Bound::Pct(0.5));
1663    }
1664
1665    #[test]
1666    fn parse_bound_percentage_out_of_range_rejected() {
1667        assert!(parse_bound("101%").is_err());
1668        assert!(parse_bound("-1%").is_err());
1669    }
1670
1671    #[test]
1672    fn parse_bound_fraction() {
1673        assert_eq!(parse_bound("0.5").unwrap(), Bound::Frac(0.5));
1674        assert_eq!(parse_bound("0.0").unwrap(), Bound::Frac(0.0));
1675        assert_eq!(parse_bound("1.0").unwrap(), Bound::Frac(1.0));
1676        assert_eq!(parse_bound("0.123").unwrap(), Bound::Frac(0.123));
1677    }
1678
1679    #[test]
1680    fn parse_bound_fraction_out_of_range_rejected() {
1681        let err = parse_bound("1.5").unwrap_err();
1682        assert!(
1683            err.contains("ambiguous"),
1684            "diagnostic should explain: {err}"
1685        );
1686    }
1687
1688    #[test]
1689    fn parse_bound_literal_ordinal() {
1690        assert_eq!(parse_bound("0").unwrap(), Bound::Ord(0));
1691        assert_eq!(parse_bound("100").unwrap(), Bound::Ord(100));
1692        assert_eq!(parse_bound("999999").unwrap(), Bound::Ord(999_999));
1693    }
1694
1695    #[test]
1696    fn parse_bound_star_token() {
1697        assert_eq!(parse_bound("*").unwrap(), Bound::Star);
1698        assert_eq!(parse_bound("*%").unwrap(), Bound::Star);
1699    }
1700
1701    // ── Form 1: single sub-range ───────────────────────────
1702
1703    #[test]
1704    fn parse_form1_simple_pct() {
1705        let spec = parse("0..53%").unwrap();
1706        assert_eq!(
1707            spec,
1708            PartitionSpec::single_range(Bound::Ord(0), Bound::Pct(53.0))
1709        );
1710    }
1711
1712    #[test]
1713    fn parse_form1_brackets_tolerated() {
1714        let canonical = PartitionSpec::single_range(Bound::Ord(0), Bound::Pct(53.0));
1715        assert_eq!(parse("[0..53%]").unwrap(), canonical);
1716        assert_eq!(parse("[0..53%)").unwrap(), canonical);
1717        assert_eq!(parse("(0..53%]").unwrap(), canonical);
1718    }
1719
1720    #[test]
1721    fn parse_form1_fraction_form() {
1722        let spec = parse("0..0.53").unwrap();
1723        assert_eq!(
1724            spec,
1725            PartitionSpec::single_range(Bound::Ord(0), Bound::Frac(0.53))
1726        );
1727    }
1728
1729    #[test]
1730    fn parse_form1_literal_ordinals() {
1731        let spec = parse("100..1000").unwrap();
1732        assert_eq!(
1733            spec,
1734            PartitionSpec::single_range(Bound::Ord(100), Bound::Ord(1000))
1735        );
1736    }
1737
1738    #[test]
1739    fn parse_form1_mixed_literal_and_pct() {
1740        let spec = parse("100..50%").unwrap();
1741        assert_eq!(
1742            spec,
1743            PartitionSpec::single_range(Bound::Ord(100), Bound::Pct(50.0))
1744        );
1745    }
1746
1747    #[test]
1748    fn parse_form1_mixed_frac_and_literal() {
1749        let spec = parse("0.10..10000").unwrap();
1750        assert_eq!(
1751            spec,
1752            PartitionSpec::single_range(Bound::Frac(0.10), Bound::Ord(10000))
1753        );
1754    }
1755
1756    #[test]
1757    fn parse_form1_rejects_star() {
1758        assert!(parse("0..*").is_err());
1759        assert!(parse("*..50%").is_err());
1760    }
1761
1762    // ── Form 2: delta list ─────────────────────────────────
1763
1764    #[test]
1765    fn parse_form2_with_star() {
1766        let spec = parse("2%,10%,*%").unwrap();
1767        assert_eq!(
1768            spec,
1769            PartitionSpec::delta_list(vec![Bound::Pct(2.0), Bound::Pct(10.0), Bound::Star])
1770        );
1771    }
1772
1773    #[test]
1774    fn parse_form2_fraction_equivalent() {
1775        let spec = parse("0.02,0.10,*").unwrap();
1776        assert_eq!(
1777            spec,
1778            PartitionSpec::delta_list(vec![Bound::Frac(0.02), Bound::Frac(0.10), Bound::Star])
1779        );
1780    }
1781
1782    #[test]
1783    fn parse_form2_literal_deltas() {
1784        let spec = parse("1000,5000,*").unwrap();
1785        assert_eq!(
1786            spec,
1787            PartitionSpec::delta_list(vec![Bound::Ord(1000), Bound::Ord(5000), Bound::Star])
1788        );
1789    }
1790
1791    #[test]
1792    fn parse_form2_mixed_entries() {
1793        let spec = parse("1000,10%,*").unwrap();
1794        assert_eq!(
1795            spec,
1796            PartitionSpec::delta_list(vec![Bound::Ord(1000), Bound::Pct(10.0), Bound::Star])
1797        );
1798    }
1799
1800    #[test]
1801    fn parse_form2_short_list_no_star() {
1802        let spec = parse("20%,30%").unwrap();
1803        assert_eq!(
1804            spec,
1805            PartitionSpec::delta_list(vec![Bound::Pct(20.0), Bound::Pct(30.0)])
1806        );
1807    }
1808
1809    #[test]
1810    fn parse_form2_rejects_multiple_stars() {
1811        let err = parse("*,*").unwrap_err();
1812        assert!(err.contains("at most one"), "diagnostic: {err}");
1813    }
1814
1815    // ── Form 2 tail tokens: `...` fill and `*/N` split ─────
1816
1817    #[test]
1818    fn parse_form2_fill_token() {
1819        let spec = parse("90%,1%,...").unwrap();
1820        assert_eq!(
1821            spec,
1822            PartitionSpec::delta_list(vec![Bound::Pct(90.0), Bound::Pct(1.0), Bound::Fill])
1823        );
1824    }
1825
1826    #[test]
1827    fn parse_form2_star_split_token() {
1828        let spec = parse("90%,*/10").unwrap();
1829        assert_eq!(
1830            spec,
1831            PartitionSpec::delta_list(vec![Bound::Pct(90.0), Bound::StarSplit(10)])
1832        );
1833    }
1834
1835    #[test]
1836    fn parse_star_split_alone_is_whole_extent_split() {
1837        // Degenerate no-head case: the remainder is everything.
1838        let spec = parse("*/16").unwrap();
1839        assert_eq!(spec, PartitionSpec::delta_list(vec![Bound::StarSplit(16)]));
1840    }
1841
1842    #[test]
1843    fn parse_fill_alone_rejected_with_hint() {
1844        let err = parse("...").unwrap_err();
1845        assert!(
1846            err.contains("preceding delta") || err.contains("before it"),
1847            "diagnostic: {err}"
1848        );
1849    }
1850
1851    #[test]
1852    fn parse_fill_first_in_list_rejected() {
1853        let err = parse("...,10%").unwrap_err();
1854        assert!(
1855            err.contains("before it") || err.contains("last entry"),
1856            "diagnostic: {err}"
1857        );
1858    }
1859
1860    #[test]
1861    fn parse_fill_not_last_rejected() {
1862        let err = parse("1%,...,10%").unwrap_err();
1863        assert!(err.contains("last entry"), "diagnostic: {err}");
1864    }
1865
1866    #[test]
1867    fn parse_star_split_not_last_rejected() {
1868        let err = parse("*/4,10%").unwrap_err();
1869        assert!(err.contains("last entry"), "diagnostic: {err}");
1870    }
1871
1872    #[test]
1873    fn parse_rejects_mixed_tail_tokens() {
1874        let err = parse("1%,*,...").unwrap_err();
1875        assert!(err.contains("at most one"), "diagnostic: {err}");
1876        let err = parse("1%,*,*/4").unwrap_err();
1877        assert!(err.contains("at most one"), "diagnostic: {err}");
1878    }
1879
1880    #[test]
1881    fn parse_star_split_pct_divisor_rejected_with_teaching_hint() {
1882        // `*/1%` is the chunk-SIZE reading; one canonical
1883        // spelling for that exists (`1%,...`), and the
1884        // diagnostic must point at it.
1885        let err = parse("90%,*/1%").unwrap_err();
1886        assert!(err.contains("chunk count"), "diagnostic: {err}");
1887        assert!(
1888            err.contains("1%,..."),
1889            "diagnostic should teach the fill form: {err}"
1890        );
1891        let err = parse("90%,*/0.01").unwrap_err();
1892        assert!(err.contains("chunk count"), "diagnostic: {err}");
1893    }
1894
1895    #[test]
1896    fn parse_star_split_zero_rejected() {
1897        let err = parse("90%,*/0").unwrap_err();
1898        assert!(err.contains(">= 1"), "diagnostic: {err}");
1899    }
1900
1901    #[test]
1902    fn parse_form1_rejects_tail_tokens() {
1903        assert!(parse("0..*/4").is_err());
1904        // `0....` reads as `0..` + `..` noise — any tail token in
1905        // a range position must fail to parse, one way or another.
1906        assert!(parse("0....").is_err());
1907    }
1908
1909    // ── Form 3: pre-baked recipes ──────────────────────────
1910
1911    fn deltas_only(spec: PartitionSpec) -> Vec<Bound> {
1912        match spec.chunking {
1913            Chunking::DeltaList { deltas } => deltas,
1914            other => panic!("expected DeltaList, got {other:?}"),
1915        }
1916    }
1917
1918    fn pcts_of(spec: PartitionSpec) -> Vec<f64> {
1919        deltas_only(spec)
1920            .into_iter()
1921            .map(|b| match b {
1922                Bound::Pct(p) => p,
1923                other => panic!("expected Pct, got {other:?}"),
1924            })
1925            .collect()
1926    }
1927
1928    #[test]
1929    fn recipe_linear_uniform_split() {
1930        let pcts = pcts_of(parse("linear:4").unwrap());
1931        assert_eq!(pcts.len(), 4);
1932        for p in &pcts {
1933            assert!((p - 25.0).abs() < 1e-9, "expected 25%, got {p}");
1934        }
1935    }
1936
1937    #[test]
1938    fn recipe_ratios_normalises_weights() {
1939        let pcts = pcts_of(parse("ratios:1,1,2").unwrap());
1940        assert_eq!(pcts.len(), 3);
1941        assert!((pcts[0] - 25.0).abs() < 1e-9);
1942        assert!((pcts[1] - 25.0).abs() < 1e-9);
1943        assert!((pcts[2] - 50.0).abs() < 1e-9);
1944    }
1945
1946    #[test]
1947    fn recipe_bin_5_is_five_terms_of_binomial_expansion() {
1948        // C(4, k) for k = 0..4 → [1, 4, 6, 4, 1], sum 16.
1949        let pcts = pcts_of(parse("bin:5").unwrap());
1950        assert_eq!(pcts.len(), 5);
1951        let expected = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
1952        for (i, e) in expected.iter().enumerate() {
1953            assert!(
1954                (pcts[i] - e * 100.0).abs() < 1e-9,
1955                "term {i}: {} vs {}",
1956                pcts[i],
1957                e * 100.0
1958            );
1959        }
1960    }
1961
1962    #[test]
1963    fn recipe_fib_7_uses_distinct_fibonacci() {
1964        // 1, 2, 3, 5, 8, 13, 21 — sum 53.
1965        let pcts = pcts_of(parse("fib:7").unwrap());
1966        assert_eq!(pcts.len(), 7);
1967        let expected_weights = [1.0, 2.0, 3.0, 5.0, 8.0, 13.0, 21.0];
1968        let sum: f64 = expected_weights.iter().sum();
1969        for (i, w) in expected_weights.iter().enumerate() {
1970            assert!((pcts[i] - w / sum * 100.0).abs() < 1e-9);
1971        }
1972    }
1973
1974    #[test]
1975    fn recipe_ln_5_log_spaced() {
1976        let pcts = pcts_of(parse("ln:5").unwrap());
1977        assert_eq!(pcts.len(), 5);
1978        // Monotonically increasing weights.
1979        for i in 1..pcts.len() {
1980            assert!(pcts[i] > pcts[i - 1], "ln:N should be monotonic");
1981        }
1982        // Sum to 100.
1983        let total: f64 = pcts.iter().sum();
1984        assert!((total - 100.0).abs() < 1e-9, "total: {total}");
1985    }
1986
1987    #[test]
1988    fn recipe_mul_decay_tail_off() {
1989        // Decay case: R < 1, terms shrink. Stop when current
1990        // term is < 0.1% of the starting weight.
1991        let pcts = pcts_of(parse("mul:0.5").unwrap());
1992        assert!(!pcts.is_empty());
1993        let total: f64 = pcts.iter().sum();
1994        assert!((total - 100.0).abs() < 1e-9, "total: {total}");
1995        // 1, 0.5, 0.25, ... — first partition should be the dominant one.
1996        assert!(pcts[0] > pcts[1]);
1997    }
1998
1999    #[test]
2000    fn recipe_mul_growth_caps_at_3_orders_of_magnitude() {
2001        // Growth case: R > 1, terms grow. Stop when terms span
2002        // ~3 orders of magnitude. Sum normalises cleanly.
2003        let pcts = pcts_of(parse("mul:2").unwrap());
2004        assert!(!pcts.is_empty());
2005        assert!(pcts.len() < 64, "should terminate well before hard cap");
2006        let total: f64 = pcts.iter().sum();
2007        assert!((total - 100.0).abs() < 1e-9, "total: {total}");
2008    }
2009
2010    #[test]
2011    fn recipe_mul_with_start_and_ratio() {
2012        // mul:S,R — start at S, compound by R.
2013        let pcts = pcts_of(parse("mul:5,0.5").unwrap());
2014        let total: f64 = pcts.iter().sum();
2015        assert!((total - 100.0).abs() < 1e-9, "total: {total}");
2016    }
2017
2018    #[test]
2019    fn recipe_geom_fixed_term_count() {
2020        let pcts = pcts_of(parse("geom:5,2").unwrap());
2021        assert_eq!(pcts.len(), 5);
2022        // Weights are 1, 2, 4, 8, 16 — sum 31.
2023        let expected_total: f64 = 31.0;
2024        let expected = [1.0, 2.0, 4.0, 8.0, 16.0];
2025        for (i, e) in expected.iter().enumerate() {
2026            assert!((pcts[i] - e / expected_total * 100.0).abs() < 1e-9);
2027        }
2028    }
2029
2030    #[test]
2031    fn recipe_front_heavy_declining() {
2032        let pcts = pcts_of(parse("front_heavy:4").unwrap());
2033        assert_eq!(pcts.len(), 4);
2034        for i in 1..pcts.len() {
2035            assert!(
2036                pcts[i] < pcts[i - 1],
2037                "front_heavy should be monotonic-declining"
2038            );
2039        }
2040    }
2041
2042    #[test]
2043    fn recipe_back_heavy_growing() {
2044        let pcts = pcts_of(parse("back_heavy:4").unwrap());
2045        assert_eq!(pcts.len(), 4);
2046        for i in 1..pcts.len() {
2047            assert!(
2048                pcts[i] > pcts[i - 1],
2049                "back_heavy should be monotonic-growing"
2050            );
2051        }
2052    }
2053
2054    #[test]
2055    fn recipe_unknown_name_rejected() {
2056        let err = parse("blorp:3").unwrap_err();
2057        assert!(err.contains("unknown recipe"), "diagnostic: {err}");
2058        assert!(
2059            err.contains("linear"),
2060            "should list supported recipes: {err}"
2061        );
2062    }
2063
2064    // ── Resolution ──────────────────────────────────────────
2065
2066    #[test]
2067    fn resolve_form1_percentage_against_extent() {
2068        let spec = parse("0..50%").unwrap();
2069        let parts = resolve(&spec, 0, 1000).unwrap();
2070        assert_eq!(parts.len(), 1);
2071        assert_eq!(parts[0].start_ord, 0);
2072        assert_eq!(parts[0].end_ord, 500);
2073        assert_eq!(parts[0].cardinality(), 500);
2074    }
2075
2076    #[test]
2077    fn resolve_form1_literal_ordinals() {
2078        let spec = parse("100..1000").unwrap();
2079        let parts = resolve(&spec, 0, 10000).unwrap();
2080        assert_eq!(parts[0].start_ord, 100);
2081        assert_eq!(parts[0].end_ord, 1000);
2082        assert_eq!(parts[0].cardinality(), 900);
2083    }
2084
2085    #[test]
2086    fn resolve_form1_mixed_literal_and_pct() {
2087        let spec = parse("100..50%").unwrap();
2088        let parts = resolve(&spec, 0, 1000).unwrap();
2089        assert_eq!(parts[0].start_ord, 100);
2090        assert_eq!(parts[0].end_ord, 500);
2091    }
2092
2093    #[test]
2094    fn resolve_form2_three_partition_pct_list() {
2095        let spec = parse("2%,10%,*%").unwrap();
2096        let parts = resolve(&spec, 0, 1000).unwrap();
2097        assert_eq!(parts.len(), 3);
2098        assert_eq!(parts[0].start_ord, 0);
2099        assert_eq!(parts[0].end_ord, 20);
2100        assert_eq!(parts[1].start_ord, 20);
2101        assert_eq!(parts[1].end_ord, 120);
2102        assert_eq!(parts[2].start_ord, 120);
2103        assert_eq!(parts[2].end_ord, 1000);
2104        assert_eq!(parts[2].cardinality(), 880);
2105    }
2106
2107    #[test]
2108    fn resolve_form2_literal_deltas() {
2109        let spec = parse("1000,5000,*").unwrap();
2110        let parts = resolve(&spec, 0, 10000).unwrap();
2111        assert_eq!(parts.len(), 3);
2112        assert_eq!(parts[0].start_ord, 0);
2113        assert_eq!(parts[0].end_ord, 1000);
2114        assert_eq!(parts[1].start_ord, 1000);
2115        assert_eq!(parts[1].end_ord, 6000);
2116        assert_eq!(parts[2].start_ord, 6000);
2117        assert_eq!(parts[2].end_ord, 10000);
2118    }
2119
2120    #[test]
2121    fn resolve_form2_mixed_literal_and_pct_with_star() {
2122        let spec = parse("1000,10%,*").unwrap();
2123        let parts = resolve(&spec, 0, 10000).unwrap();
2124        assert_eq!(parts.len(), 3);
2125        assert_eq!(parts[0].cardinality(), 1000);
2126        assert_eq!(parts[1].cardinality(), 1000); // 10% of 10000
2127        assert_eq!(parts[2].cardinality(), 8000); // remainder
2128    }
2129
2130    #[test]
2131    fn resolve_form2_short_list_drops_trailing_gap() {
2132        let spec = parse("20%,30%").unwrap();
2133        let parts = resolve(&spec, 0, 1000).unwrap();
2134        assert_eq!(parts.len(), 2);
2135        assert_eq!(parts[0].end_ord, 200);
2136        assert_eq!(parts[1].end_ord, 500); // 50% boundary; trailing 50% gap dropped
2137    }
2138
2139    #[test]
2140    fn resolve_rejects_over_extent_sum() {
2141        let spec = parse("60%,60%").unwrap();
2142        let err = resolve(&spec, 0, 1000).unwrap_err();
2143        assert!(err.contains("exceeding"), "diagnostic: {err}");
2144    }
2145
2146    #[test]
2147    fn resolve_recipe_against_extent() {
2148        let spec = parse("linear:4").unwrap();
2149        let parts = resolve(&spec, 0, 1000).unwrap();
2150        assert_eq!(parts.len(), 4);
2151        for p in &parts {
2152            assert_eq!(p.cardinality(), 250);
2153        }
2154    }
2155
2156    #[test]
2157    fn resolve_partition_indices_assigned() {
2158        let spec = parse("linear:5").unwrap();
2159        let parts = resolve(&spec, 0, 1000).unwrap();
2160        for (i, p) in parts.iter().enumerate() {
2161            assert_eq!(p.idx, i as u64);
2162        }
2163    }
2164
2165    #[test]
2166    fn resolve_partition_pcts_populated() {
2167        let spec = parse("linear:4").unwrap();
2168        let parts = resolve(&spec, 0, 1000).unwrap();
2169        assert!((parts[0].start_pct - 0.0).abs() < 1e-9);
2170        assert!((parts[0].end_pct - 25.0).abs() < 1e-9);
2171        assert!((parts[3].end_pct - 100.0).abs() < 1e-9);
2172    }
2173
2174    // ── Resolution: tail tokens ─────────────────────────────
2175
2176    /// The three spellings of "first 90%, then the rest in ten
2177    /// 1%-of-the-whole chunks" that coincide at head=90%:
2178    /// explicit enumeration, fill, and remainder split.
2179    #[test]
2180    fn resolve_fill_and_star_split_coincide_at_90_10() {
2181        let explicit = resolve(
2182            &parse("90%,1%,1%,1%,1%,1%,1%,1%,1%,1%,1%").unwrap(),
2183            0,
2184            1000,
2185        )
2186        .unwrap();
2187        let filled = resolve(&parse("90%,1%,...").unwrap(), 0, 1000).unwrap();
2188        let split = resolve(&parse("90%,*/10").unwrap(), 0, 1000).unwrap();
2189        assert_eq!(explicit.len(), 11);
2190        assert_eq!(filled, explicit);
2191        assert_eq!(split, explicit);
2192        assert_eq!(filled[0].cardinality(), 900);
2193        for p in &filled[1..] {
2194            assert_eq!(p.cardinality(), 10);
2195        }
2196        assert_eq!(filled[10].end_ord, 1000);
2197    }
2198
2199    #[test]
2200    fn resolve_fill_truncates_final_chunk() {
2201        // 3 + 2 + 2 + 2 + 1(truncated) over extent 10.
2202        let parts = resolve(&parse("3,2,...").unwrap(), 0, 10).unwrap();
2203        let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2204        assert_eq!(bounds, vec![(0, 3), (3, 5), (5, 7), (7, 9), (9, 10)]);
2205    }
2206
2207    #[test]
2208    fn resolve_fill_with_nothing_left_adds_no_chunks() {
2209        // 90% + 10% covers the extent; `...` repeats 10% zero times.
2210        let parts = resolve(&parse("90%,10%,...").unwrap(), 0, 1000).unwrap();
2211        assert_eq!(parts.len(), 2);
2212        assert_eq!(parts[1].end_ord, 1000);
2213    }
2214
2215    #[test]
2216    fn resolve_fill_subordinal_chunk_rejected() {
2217        // 0.01% of 100 is 0.01 ordinals — chunks under one
2218        // ordinal could never all be non-empty.
2219        let err = resolve(&parse("50%,0.01%,...").unwrap(), 0, 100).unwrap_err();
2220        assert!(err.contains("less than one ordinal"), "diagnostic: {err}");
2221    }
2222
2223    #[test]
2224    fn resolve_pct_boundaries_round_at_cumulative_position() {
2225        // linear:3 over 1000 — per-entry rounding would give
2226        // 333/333/333 and silently drop ordinal 999; the
2227        // boundary rule distributes the slack: 333/334/333,
2228        // covering the extent exactly.
2229        let parts = resolve(&parse("linear:3").unwrap(), 0, 1000).unwrap();
2230        let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2231        assert_eq!(bounds, vec![(0, 333), (333, 667), (667, 1000)]);
2232    }
2233
2234    #[test]
2235    fn resolve_star_split_distributes_rounding_slack() {
2236        // Remainder of 100 into 3: sizes 33/34/33 (rounded
2237        // boundaries), contiguous, exactly covering the extent.
2238        let parts = resolve(&parse("*/3").unwrap(), 0, 100).unwrap();
2239        assert_eq!(parts.len(), 3);
2240        assert_eq!(parts[0].start_ord, 0);
2241        assert_eq!(parts[2].end_ord, 100);
2242        for w in parts.windows(2) {
2243            assert_eq!(w[0].end_ord, w[1].start_ord, "contiguous");
2244        }
2245        let sizes: Vec<u64> = parts.iter().map(|p| p.cardinality()).collect();
2246        assert!(
2247            sizes.iter().all(|s| *s == 33 || *s == 34),
2248            "sizes: {sizes:?}"
2249        );
2250        assert_eq!(sizes.iter().sum::<u64>(), 100);
2251    }
2252
2253    #[test]
2254    fn resolve_star_split_alone_equals_linear_recipe() {
2255        let split = resolve(&parse("*/16").unwrap(), 0, 1600).unwrap();
2256        let linear = resolve(&parse("linear:16").unwrap(), 0, 1600).unwrap();
2257        assert_eq!(split, linear);
2258    }
2259
2260    #[test]
2261    fn resolve_star_split_no_remainder_rejected() {
2262        let err = resolve(&parse("100%,*/4").unwrap(), 0, 1000).unwrap_err();
2263        assert!(err.contains("no remainder"), "diagnostic: {err}");
2264    }
2265
2266    #[test]
2267    fn resolve_star_split_finer_than_remainder_rejected() {
2268        let err = resolve(&parse("90%,*/200").unwrap(), 0, 1000).unwrap_err();
2269        assert!(err.contains("non-empty"), "diagnostic: {err}");
2270    }
2271
2272    #[test]
2273    fn resolve_tail_indices_continue_from_head() {
2274        let parts = resolve(&parse("50%,*/5").unwrap(), 0, 1000).unwrap();
2275        assert_eq!(parts.len(), 6);
2276        for (i, p) in parts.iter().enumerate() {
2277            assert_eq!(p.idx, i as u64);
2278        }
2279    }
2280
2281    #[test]
2282    fn split_evenly_boundaries_monotone_and_exact() {
2283        for (start, end, n) in [
2284            (0u64, 100u64, 7u64),
2285            (5, 5, 1),
2286            (0, 3, 3),
2287            (1000, 10007, 13),
2288        ] {
2289            let chunks = split_evenly(start, end, n);
2290            assert_eq!(chunks.len(), n as usize);
2291            assert_eq!(chunks[0].0, start);
2292            assert_eq!(chunks[n as usize - 1].1, end);
2293            for w in chunks.windows(2) {
2294                assert_eq!(w[0].1, w[1].0);
2295            }
2296            let total: u64 = chunks.iter().map(|(s, e)| e - s).sum();
2297            assert_eq!(total, end - start);
2298        }
2299    }
2300
2301    // ── Whitespace tolerance ───────────────────────────────
2302
2303    #[test]
2304    fn parse_tolerates_whitespace_in_lists() {
2305        let spec = parse(" 2% , 10% , *% ").unwrap();
2306        assert_eq!(
2307            spec,
2308            PartitionSpec::delta_list(vec![Bound::Pct(2.0), Bound::Pct(10.0), Bound::Star])
2309        );
2310    }
2311
2312    // ── Polydat Value round-trip ────────────────────────────────
2313
2314    #[test]
2315    fn partition_roundtrips_through_value_ext() {
2316        let p = Partition {
2317            idx: 2,
2318            count: 4,
2319            start_ord: 100,
2320            end_ord: 500,
2321            start_pct: 10.0,
2322            end_pct: 50.0,
2323            base_extent: 1000,
2324        };
2325        let v = Value::from_partition(p);
2326        let recovered = v.as_partition().expect("downcast");
2327        assert_eq!(recovered.idx, 2);
2328        assert_eq!(recovered.start_ord, 100);
2329        assert_eq!(recovered.end_ord, 500);
2330        assert_eq!(recovered.cardinality(), 400);
2331    }
2332
2333    #[test]
2334    fn partition_spec_roundtrips_through_value_ext() {
2335        let spec = parse("fib:5").unwrap();
2336        let v = Value::from_partition_spec(spec);
2337        let recovered = v.as_partition_spec().expect("downcast");
2338        // Just sanity-check it's a DeltaList with 5 entries
2339        match &recovered.chunking {
2340            Chunking::DeltaList { deltas } => assert_eq!(deltas.len(), 5),
2341            other => panic!("expected DeltaList, got {other:?}"),
2342        }
2343    }
2344
2345    #[test]
2346    fn partition_list_roundtrips_through_value_ext() {
2347        let spec = parse("linear:4").unwrap();
2348        let parts = resolve(&spec, 0, 1000).unwrap();
2349        let v = Value::from_partition_list(parts);
2350        let recovered = v.as_partition_list().expect("downcast");
2351        assert_eq!(recovered.len(), 4);
2352        assert_eq!(recovered.as_slice()[0].start_ord, 0);
2353        assert_eq!(recovered.as_slice()[3].end_ord, 1000);
2354    }
2355
2356    #[test]
2357    fn non_partition_value_downcast_returns_none() {
2358        let v = Value::U64(42);
2359        assert!(v.as_partition().is_none());
2360        assert!(v.as_partition_spec().is_none());
2361        assert!(v.as_partition_list().is_none());
2362    }
2363
2364    #[test]
2365    fn parse_tolerates_whitespace_in_range() {
2366        let spec = parse(" 0 .. 53 % ").unwrap();
2367        assert_eq!(
2368            spec,
2369            PartitionSpec::single_range(Bound::Ord(0), Bound::Pct(53.0))
2370        );
2371    }
2372
2373    // ── Windowed chunking (`in <window>`) ──────────────────
2374
2375    #[test]
2376    fn parse_window_clause() {
2377        let spec = parse("linear:4 in 25%..75%").unwrap();
2378        assert_eq!(spec.window, Some((Bound::Pct(25.0), Bound::Pct(75.0))));
2379        assert_eq!(spec.order, PartitionOrder::Unchanged);
2380        match &spec.chunking {
2381            Chunking::DeltaList { deltas } => assert_eq!(deltas.len(), 4),
2382            other => panic!("expected DeltaList, got {other:?}"),
2383        }
2384    }
2385
2386    #[test]
2387    fn parse_window_requires_range() {
2388        let err = parse("linear:4 in 50%").unwrap_err();
2389        assert!(err.contains("start..end"), "diagnostic: {err}");
2390    }
2391
2392    #[test]
2393    fn parse_window_requires_sized_bounds() {
2394        let err = parse("linear:4 in 0..*").unwrap_err();
2395        assert!(err.contains("sized"), "diagnostic: {err}");
2396    }
2397
2398    #[test]
2399    fn parse_window_clause_position_errors() {
2400        assert!(parse("in 0..50%").unwrap_err().contains("chunking spec"));
2401        assert!(parse("linear:4 in").unwrap_err().contains("window range"));
2402        assert!(
2403            parse("linear:2 in 0..50% in 0..10%")
2404                .unwrap_err()
2405                .contains("at most one")
2406        );
2407    }
2408
2409    #[test]
2410    fn resolve_windowed_chunking_is_window_relative() {
2411        // The chunking resolves against the window's range:
2412        // linear:4 over [20%, 100%) of 1000 → four 200-ordinal
2413        // partitions starting at 200.
2414        let parts = resolve(&parse("linear:4 in 20%..100%").unwrap(), 0, 1000).unwrap();
2415        let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2416        assert_eq!(
2417            bounds,
2418            vec![(200, 400), (400, 600), (600, 800), (800, 1000)]
2419        );
2420    }
2421
2422    #[test]
2423    fn resolve_windowed_form1_composes() {
2424        // `0..50%` of the window [500, 1000) → [500, 750).
2425        let parts = resolve(&parse("0..50% in 50%..100%").unwrap(), 0, 1000).unwrap();
2426        assert_eq!(parts.len(), 1);
2427        assert_eq!((parts[0].start_ord, parts[0].end_ord), (500, 750));
2428    }
2429
2430    #[test]
2431    fn resolve_windowed_tail_tokens() {
2432        // `90%,*/10` inside a window: percentages are relative
2433        // to the window (here [0, 500)), so head = 450 and the
2434        // ten chunks split the remaining 50.
2435        let parts = resolve(&parse("90%,*/10 in 0..50%").unwrap(), 0, 1000).unwrap();
2436        assert_eq!(parts.len(), 11);
2437        assert_eq!((parts[0].start_ord, parts[0].end_ord), (0, 450));
2438        assert_eq!(parts[10].end_ord, 500);
2439        assert_eq!(parts[1].cardinality(), 5);
2440    }
2441
2442    // ── Finite repetition (`<delta>xN`) ────────────────────
2443
2444    #[test]
2445    fn parse_finite_repetition_expands() {
2446        let spec = parse("1%x3").unwrap();
2447        assert_eq!(spec, PartitionSpec::delta_list(vec![Bound::Pct(1.0); 3]));
2448    }
2449
2450    #[test]
2451    fn parse_repetition_zero_rejected() {
2452        let err = parse("1%x0").unwrap_err();
2453        assert!(err.contains(">= 1"), "diagnostic: {err}");
2454    }
2455
2456    #[test]
2457    fn parse_repetition_on_tail_rejected() {
2458        assert!(parse("*x3").is_err());
2459        assert!(parse("...x3").is_err());
2460    }
2461
2462    #[test]
2463    fn resolve_repetition_equals_fill_and_split_at_90_10() {
2464        // The FOURTH spelling of "first 90%, then ten 1% chunks":
2465        // size and count both declared.
2466        let explicit = resolve(&parse("90%,1%,...").unwrap(), 0, 1000).unwrap();
2467        let repeated = resolve(&parse("90%,1%x10").unwrap(), 0, 1000).unwrap();
2468        assert_eq!(repeated, explicit);
2469    }
2470
2471    // ── Gaps (`~<delta>`) ──────────────────────────────────
2472
2473    #[test]
2474    fn parse_gap_entry() {
2475        let spec = parse("10%,~80%,10%").unwrap();
2476        assert_eq!(
2477            spec,
2478            PartitionSpec::delta_list(vec![
2479                Bound::Pct(10.0),
2480                Bound::Gap(Box::new(Bound::Pct(80.0))),
2481                Bound::Pct(10.0),
2482            ])
2483        );
2484    }
2485
2486    #[test]
2487    fn parse_gap_requires_sized_bound() {
2488        let err = parse("10%,~*").unwrap_err();
2489        assert!(err.contains("sized"), "diagnostic: {err}");
2490    }
2491
2492    #[test]
2493    fn parse_gap_repetition_rejected() {
2494        let err = parse("10%,~10%x3").unwrap_err();
2495        assert!(err.contains("size the gap"), "diagnostic: {err}");
2496    }
2497
2498    #[test]
2499    fn parse_all_gaps_rejected() {
2500        let err = parse("~10%,~20%").unwrap_err();
2501        assert!(err.contains("emits no partitions"), "diagnostic: {err}");
2502    }
2503
2504    #[test]
2505    fn parse_fill_after_gap_rejected() {
2506        let err = parse("5%,~5%,...").unwrap_err();
2507        assert!(err.contains("emit nothing"), "diagnostic: {err}");
2508    }
2509
2510    #[test]
2511    fn resolve_gap_consumes_without_emitting() {
2512        let parts = resolve(&parse("10%,~80%,10%").unwrap(), 0, 1000).unwrap();
2513        let bounds: Vec<(u64, u64, u64)> = parts
2514            .iter()
2515            .map(|p| (p.idx, p.start_ord, p.end_ord))
2516            .collect();
2517        // Emitted partitions only; idx counts emitted entries.
2518        assert_eq!(bounds, vec![(0, 0, 100), (1, 900, 1000)]);
2519    }
2520
2521    #[test]
2522    fn resolve_gap_counts_toward_star_remainder() {
2523        // 10% head + 40% gap leaves 50% for the star.
2524        let parts = resolve(&parse("10%,~40%,*").unwrap(), 0, 1000).unwrap();
2525        let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2526        assert_eq!(bounds, vec![(0, 100), (500, 1000)]);
2527    }
2528
2529    // ── Recipe-shaped remainder (`*/recipe`) ───────────────
2530
2531    #[test]
2532    fn parse_star_shaped_recipe() {
2533        let spec = parse("50%,*/ratios:1,3").unwrap();
2534        match &spec.chunking {
2535            Chunking::DeltaList { deltas } => {
2536                assert_eq!(deltas.len(), 2);
2537                match &deltas[1] {
2538                    Bound::StarShaped(w) => {
2539                        assert_eq!(w.len(), 2);
2540                        assert!((w[0] - 25.0).abs() < 1e-9);
2541                        assert!((w[1] - 75.0).abs() < 1e-9);
2542                    }
2543                    other => panic!("expected StarShaped, got {other:?}"),
2544                }
2545            }
2546            other => panic!("expected DeltaList, got {other:?}"),
2547        }
2548    }
2549
2550    #[test]
2551    fn parse_star_linear_rejected_with_canonical_hint() {
2552        let err = parse("90%,*/linear:4").unwrap_err();
2553        assert!(
2554            err.contains("*/4"),
2555            "diagnostic should point at `*/N`: {err}"
2556        );
2557    }
2558
2559    #[test]
2560    fn resolve_star_shaped_divides_remainder_by_weights() {
2561        // Remainder 500, weights 25/75 → [500, 625), [625, 1000).
2562        let parts = resolve(&parse("50%,*/ratios:1,3").unwrap(), 0, 1000).unwrap();
2563        let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2564        assert_eq!(bounds, vec![(0, 500), (500, 625), (625, 1000)]);
2565    }
2566
2567    #[test]
2568    fn resolve_star_shaped_alone_covers_extent() {
2569        let parts = resolve(&parse("*/fib:3").unwrap(), 0, 600).unwrap();
2570        // fib:3 weights [1, 2, 3] → 100/200/300.
2571        let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2572        assert_eq!(bounds, vec![(0, 100), (100, 300), (300, 600)]);
2573    }
2574
2575    #[test]
2576    fn resolve_star_shaped_empty_chunk_rejected() {
2577        // A 0.1%-ish weight of a tiny remainder rounds to zero.
2578        let err = resolve(&parse("90%,*/ratios:1,1000").unwrap(), 0, 100).unwrap_err();
2579        assert!(err.contains("empty partition"), "diagnostic: {err}");
2580    }
2581
2582    // ── Ordering suffix ────────────────────────────────────
2583
2584    #[test]
2585    fn parse_order_suffix() {
2586        assert_eq!(
2587            parse("fib:5 largest_first").unwrap().order,
2588            PartitionOrder::LargestFirst
2589        );
2590        assert_eq!(
2591            parse("fib:5 smallest_first").unwrap().order,
2592            PartitionOrder::SmallestFirst
2593        );
2594        assert_eq!(parse("fib:5 random").unwrap().order, PartitionOrder::Random);
2595        assert_eq!(
2596            parse("fib:5 unchanged").unwrap().order,
2597            PartitionOrder::Unchanged
2598        );
2599        assert_eq!(parse("fib:5").unwrap().order, PartitionOrder::Unchanged);
2600    }
2601
2602    #[test]
2603    fn parse_unknown_order_rejected() {
2604        let err = parse("fib:5 descend").unwrap_err();
2605        assert!(err.contains("unknown order"), "diagnostic: {err}");
2606        assert!(
2607            err.contains("largest_first"),
2608            "diagnostic should list options: {err}"
2609        );
2610    }
2611
2612    #[test]
2613    fn parse_bare_direction_words_rejected_with_axis_hint() {
2614        // `ascending` / `descending` are ambiguous between
2615        // ordinal position and size; the diagnostics teach the
2616        // axis-named spellings.
2617        let err = parse("fib:5 ascending").unwrap_err();
2618        assert!(err.contains("smallest_first"), "diagnostic: {err}");
2619        assert!(
2620            err.contains("SIZE"),
2621            "diagnostic should name the axis: {err}"
2622        );
2623        let err = parse("fib:5 descending").unwrap_err();
2624        assert!(err.contains("largest_first"), "diagnostic: {err}");
2625    }
2626
2627    #[test]
2628    fn resolve_largest_first_sorts_by_cardinality_keeping_idx() {
2629        let parts = resolve(&parse("fib:5 largest_first").unwrap(), 0, 1000).unwrap();
2630        for w in parts.windows(2) {
2631            assert!(w[0].cardinality() >= w[1].cardinality(), "largest first");
2632        }
2633        // fib weights grow, so the largest partition was
2634        // generated last: iteration starts at idx 4.
2635        assert_eq!(parts[0].idx, 4);
2636        assert_eq!(parts[4].idx, 0);
2637    }
2638
2639    #[test]
2640    fn resolve_smallest_first_is_stable_for_equal_sizes() {
2641        // Equal-sized partitions keep generation order.
2642        let parts = resolve(&parse("linear:3 smallest_first").unwrap(), 0, 999).unwrap();
2643        let idxs: Vec<u64> = parts.iter().map(|p| p.idx).collect();
2644        assert_eq!(idxs, vec![0, 1, 2]);
2645    }
2646
2647    #[test]
2648    fn resolve_random_is_deterministic_permutation() {
2649        let a = resolve(&parse("linear:8 random").unwrap(), 0, 800).unwrap();
2650        let b = resolve(&parse("linear:8 random").unwrap(), 0, 800).unwrap();
2651        assert_eq!(a, b, "same spec must shuffle identically");
2652        let mut by_idx = a.clone();
2653        by_idx.sort_by_key(|p| p.idx);
2654        let unchanged = resolve(&parse("linear:8").unwrap(), 0, 800).unwrap();
2655        assert_eq!(
2656            by_idx, unchanged,
2657            "shuffle is a permutation of the same partitions"
2658        );
2659        assert_ne!(
2660            a, unchanged,
2661            "8 elements should not shuffle to identity here"
2662        );
2663    }
2664
2665    #[test]
2666    fn display_round_trips_window_and_order() {
2667        let spec = parse("linear:2 in 0..50% largest_first").unwrap();
2668        let shown = ReflectedValue::display(&spec);
2669        assert!(shown.contains("in 0..50%"), "display: {shown}");
2670        assert!(shown.contains("largest_first"), "display: {shown}");
2671    }
2672
2673    // ── Labelling frames and degenerate extents ────────────
2674
2675    #[test]
2676    fn windowed_partitions_label_against_full_base_frame() {
2677        // The window affects sizing/placement; pct fields and
2678        // base_extent describe the FULL base. This is what lets
2679        // the `over` clause's cross-extent reprojection keep a
2680        // windowed partition's position in the whole domain
2681        // (window-relative pcts would collapse the offset:
2682        // [200, 400) would reproject as if it started at 0).
2683        let parts = resolve(&parse("linear:4 in 20%..100%").unwrap(), 0, 1000).unwrap();
2684        let p = &parts[0];
2685        assert_eq!((p.start_ord, p.end_ord), (200, 400));
2686        assert!(
2687            (p.start_pct - 20.0).abs() < 1e-9,
2688            "start_pct: {}",
2689            p.start_pct
2690        );
2691        assert!((p.end_pct - 40.0).abs() < 1e-9, "end_pct: {}", p.end_pct);
2692        assert_eq!(
2693            p.base_extent, 1000,
2694            "base_extent is the full base, not the window"
2695        );
2696    }
2697
2698    #[test]
2699    fn form1_zero_width_slice_rejected() {
2700        // `0..1%` of a 10-ordinal extent rounds to nothing — an
2701        // operator-explicit slice that would silently run zero
2702        // cycles is an error, not a no-op.
2703        let err = resolve(&parse("0..1%").unwrap(), 0, 10).unwrap_err();
2704        assert!(err.contains("zero ordinals"), "diagnostic: {err}");
2705    }
2706
2707    #[test]
2708    fn delta_list_subordinal_recipe_tails_tolerated() {
2709        // Auto-terminating recipes legitimately produce
2710        // sub-ordinal tail weights on small extents; their
2711        // zero-width entries are correct arithmetic, not an
2712        // error (contrast with the Form 1 check above).
2713        let parts = resolve(&parse("mul:0.5").unwrap(), 0, 100).unwrap();
2714        assert_eq!(
2715            parts.len(),
2716            11,
2717            "term count is weight-driven, not extent-driven"
2718        );
2719        assert_eq!(parts.last().unwrap().end_ord, 100);
2720    }
2721}
2722
2723// ── Host-side cursor narrowing ─────────────────────────────────────────
2724//
2725// A cursor declared `over <expr>` compiles to a raw output carrying the
2726// `over` value plus a set of external-write slots (`<cursor>__cursor` and
2727// its scalar projections) that stay `None` until a host resolves the
2728// value and writes them at scope setup. These helpers are that step, so
2729// every host narrows cursors the same way.
2730
2731/// Resolve the value of an `over` expression into the partitions it
2732/// denotes, against a cursor of `extent` ordinals.
2733///
2734/// A string is parsed as a partition spec and resolved against
2735/// `[0, extent)`. A `Partition` is re-projected onto `extent` from its
2736/// percentage bounds when its base extent differs. A `PartitionSpec` is
2737/// resolved. A `PartitionList` is re-projected element by element.
2738/// `Value::None` yields an empty list. Open-extent cursors reject specs,
2739/// because they have no extent to resolve against.
2740pub fn resolve_over(
2741    value: &Value,
2742    extent: u64,
2743    open_extent: bool,
2744) -> Result<Vec<Partition>, String> {
2745    let reproject = |p: &Partition| -> Partition {
2746        if open_extent || p.base_extent == extent || extent == 0 {
2747            return *p;
2748        }
2749        Partition {
2750            idx: p.idx,
2751            count: p.count,
2752            start_ord: ((p.start_pct / 100.0) * extent as f64).round() as u64,
2753            end_ord: ((p.end_pct / 100.0) * extent as f64).round() as u64,
2754            start_pct: p.start_pct,
2755            end_pct: p.end_pct,
2756            base_extent: extent,
2757        }
2758    };
2759    let reject_open = || {
2760        "an open-extent cursor has no extent to resolve a partition spec against; \
2761         resolve the spec against an explicit extent first and declare the cursor `over p`"
2762            .to_string()
2763    };
2764    match value {
2765        Value::None => Ok(Vec::new()),
2766        Value::Str(s) => {
2767            if open_extent {
2768                return Err(reject_open());
2769            }
2770            resolve(&parse(s.as_ref())?, 0, extent)
2771        }
2772        Value::Ext(b) => {
2773            if let Some(p) = value.as_partition() {
2774                Ok(vec![reproject(p)])
2775            } else if let Some(spec) = value.as_partition_spec() {
2776                if open_extent {
2777                    return Err(reject_open());
2778                }
2779                resolve(spec, 0, extent)
2780            } else if let Some(list) = value.as_partition_list() {
2781                Ok(list.as_slice().iter().map(reproject).collect())
2782            } else {
2783                Err(format!(
2784                    "`over` expression produced an Ext value of type `{}`; expected Partition, PartitionSpec, or PartitionList",
2785                    b.type_name()
2786                ))
2787            }
2788        }
2789        other => Err(format!(
2790            "`over` expression produced an unsupported value; expected a spec string or a partition-typed value, got {other:?}"
2791        )),
2792    }
2793}
2794
2795/// The extent a cursor resolves partitions against: the schema's static
2796/// extent when known, otherwise the difference of its extent outputs
2797/// pulled from `state`.
2798pub fn cursor_extent(
2799    program: &crate::kernel::PolydatProgram,
2800    state: &mut crate::kernel::PolydatState,
2801    schema: &crate::iteration::source::SourceSchema,
2802) -> u64 {
2803    if let Some((start_out, end_out)) = &schema.extent_outputs {
2804        let start = state.pull(program, start_out).as_u64();
2805        let end = state.pull(program, end_out).as_u64();
2806        let extent = end.saturating_sub(start);
2807        return schema.extent_limit.map(|l| extent.min(l)).unwrap_or(extent);
2808    }
2809    schema.extent.unwrap_or(0)
2810}
2811
2812/// Resolve the partitions a cursor's `over` clause denotes: the list the
2813/// compiler resolved at build when the clause and the extent were
2814/// constant, otherwise the raw `over` value pulled from `state` against
2815/// the extent. Returns an empty list for a cursor without an `over`
2816/// clause. The interpreter-state form of [`cursor_over_partitions_on`],
2817/// which does the same on a kernel of any engine.
2818pub fn cursor_over_partitions(
2819    program: &crate::kernel::PolydatProgram,
2820    state: &mut crate::kernel::PolydatState,
2821    schema: &crate::iteration::source::SourceSchema,
2822) -> Result<Vec<Partition>, String> {
2823    if let Some(parts) = &schema.partitions {
2824        return Ok(parts.clone());
2825    }
2826    let Some(raw) = &schema.partition_output else {
2827        return Ok(Vec::new());
2828    };
2829    let value = state.pull(program, raw).clone();
2830    let extent = cursor_extent(program, state, schema);
2831    let open = !matches!(
2832        schema.cursor_kind,
2833        crate::iteration::source::CursorKind::Range
2834    );
2835    resolve_over(&value, extent, open)
2836}
2837
2838/// [`cursor_extent`] through the [`Kernel`](crate::kernel::Kernel)
2839/// trait, for a kernel on any engine.
2840pub fn cursor_extent_on(
2841    kernel: &mut dyn crate::kernel::Kernel,
2842    schema: &crate::iteration::source::SourceSchema,
2843) -> u64 {
2844    if let Some((start_out, end_out)) = &schema.extent_outputs {
2845        let start = kernel.pull(start_out).as_u64();
2846        let end = kernel.pull(end_out).as_u64();
2847        let extent = end.saturating_sub(start);
2848        return schema.extent_limit.map(|l| extent.min(l)).unwrap_or(extent);
2849    }
2850    schema.extent.unwrap_or(0)
2851}
2852
2853/// [`cursor_over_partitions`] through the [`Kernel`](crate::kernel::Kernel)
2854/// trait, for a kernel on any engine.
2855pub fn cursor_over_partitions_on(
2856    kernel: &mut dyn crate::kernel::Kernel,
2857    schema: &crate::iteration::source::SourceSchema,
2858) -> Result<Vec<Partition>, String> {
2859    if let Some(parts) = &schema.partitions {
2860        return Ok(parts.clone());
2861    }
2862    let Some(raw) = &schema.partition_output else {
2863        return Ok(Vec::new());
2864    };
2865    let value = kernel.pull(raw);
2866    let extent = cursor_extent_on(kernel, schema);
2867    let open = !matches!(
2868        schema.cursor_kind,
2869        crate::iteration::source::CursorKind::Range
2870    );
2871    resolve_over(&value, extent, open)
2872}
2873
2874/// The inputs narrowing a cursor to one partition writes, by name: the
2875/// `<cursor>__cursor` slot and its six scalar projections. This is what
2876/// `narrow_cursor` writes on the interpreter and `set_cursor` on every
2877/// compiled kernel.
2878pub fn cursor_slot_writes(cursor_name: &str, partition: &Partition) -> [(String, Value); 7] {
2879    let slot = |suffix: &str| format!("{cursor_name}__cursor{suffix}");
2880    [
2881        (slot(""), Value::from_partition(*partition)),
2882        (slot("__idx"), Value::U64(partition.idx)),
2883        (
2884            slot("__partition_count"),
2885            Value::U64(partition.count.max(1)),
2886        ),
2887        (slot("__start_pct"), Value::F64(partition.start_pct)),
2888        (slot("__end_pct"), Value::F64(partition.end_pct)),
2889        (slot("__start_ordinal"), Value::U64(partition.start_ord)),
2890        (slot("__end_ordinal"), Value::U64(partition.end_ord)),
2891    ]
2892}
2893
2894/// Write one resolved partition into a cursor's `<cursor>__cursor` slot
2895/// and its six scalar projection slots. Slots the program does not
2896/// declare are skipped. The interpreter-state form of `set_cursor` on
2897/// the [`Kernel`](crate::kernel::Kernel) trait, which does the same on
2898/// a kernel of any engine.
2899pub fn narrow_cursor(
2900    program: &crate::kernel::PolydatProgram,
2901    state: &mut crate::kernel::PolydatState,
2902    cursor_name: &str,
2903    partition: &Partition,
2904) {
2905    for (slot, v) in cursor_slot_writes(cursor_name, partition) {
2906        if let Some(idx) = program.find_input(&slot) {
2907            state.set_input(idx, v);
2908        }
2909    }
2910}
2911
2912#[cfg(test)]
2913mod over_tests {
2914    use super::*;
2915
2916    #[test]
2917    fn resolve_over_string_spec_against_extent() {
2918        let parts = resolve_over(&Value::Str("20%,30%,*".into()), 1000, false).unwrap();
2919        let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2920        assert_eq!(bounds, vec![(0, 200), (200, 500), (500, 1000)]);
2921    }
2922
2923    #[test]
2924    fn resolve_over_reprojects_partition_onto_cursor_extent() {
2925        let p = resolve(&parse("50%..100%").unwrap(), 0, 100).unwrap()[0];
2926        let got = resolve_over(&Value::from_partition(p), 1000, false).unwrap();
2927        assert_eq!((got[0].start_ord, got[0].end_ord), (500, 1000));
2928        assert_eq!(got[0].base_extent, 1000);
2929    }
2930
2931    #[test]
2932    fn resolve_over_none_is_empty_and_open_rejects_specs() {
2933        assert!(resolve_over(&Value::None, 10, false).unwrap().is_empty());
2934        assert!(resolve_over(&Value::Str("*/2".into()), 10, true).is_err());
2935    }
2936}