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