1use std::fmt;
28use std::sync::Arc;
29
30use crate::ast::{ReflectedValue, Value};
31
32#[derive(Debug, Clone, PartialEq)]
56pub enum Bound {
57 Pct(f64),
59 Frac(f64),
62 Ord(u64),
64 Star,
68 Fill,
72 StarSplit(u64),
76 StarShaped(Vec<f64>),
80 Gap(Box<Bound>),
86}
87
88impl Bound {
89 pub fn resolve_against(&self, base_start: u64, base_end: u64) -> Option<u64> {
95 let extent = base_end.saturating_sub(base_start);
96 match self {
97 Bound::Pct(p) => Some(base_start + ((p / 100.0) * extent as f64).round() as u64),
98 Bound::Frac(f) => Some(base_start + (f * extent as f64).round() as u64),
99 Bound::Ord(o) => Some(base_start.saturating_add(*o).min(base_end)),
100 Bound::Star
101 | Bound::Fill
102 | Bound::StarSplit(_)
103 | Bound::StarShaped(_)
104 | Bound::Gap(_) => None,
105 }
106 }
107
108 pub fn is_tail(&self) -> bool {
111 matches!(
112 self,
113 Bound::Star | Bound::Fill | Bound::StarSplit(_) | Bound::StarShaped(_)
114 )
115 }
116
117 pub fn is_sized(&self) -> bool {
120 matches!(self, Bound::Pct(_) | Bound::Frac(_) | Bound::Ord(_))
121 }
122}
123
124impl fmt::Display for Bound {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 match self {
127 Bound::Pct(p) => write!(f, "{p}%"),
128 Bound::Frac(v) => write!(f, "{v}"),
129 Bound::Ord(o) => write!(f, "{o}"),
130 Bound::Star => write!(f, "*"),
131 Bound::Fill => write!(f, "..."),
132 Bound::StarSplit(n) => write!(f, "*/{n}"),
133 Bound::StarShaped(w) => {
134 let ws: Vec<String> = w.iter().map(|x| format!("{x:.3}")).collect();
135 write!(f, "*/shaped:{}", ws.join(","))
136 }
137 Bound::Gap(inner) => write!(f, "~{inner}"),
138 }
139 }
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
163pub enum PartitionOrder {
164 #[default]
166 Unchanged,
167 SmallestFirst,
169 LargestFirst,
171 Random,
173}
174
175impl fmt::Display for PartitionOrder {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 let s = match self {
178 PartitionOrder::Unchanged => "unchanged",
179 PartitionOrder::SmallestFirst => "smallest_first",
180 PartitionOrder::LargestFirst => "largest_first",
181 PartitionOrder::Random => "random",
182 };
183 write!(f, "{s}")
184 }
185}
186
187#[derive(Debug, Clone, PartialEq)]
198pub enum Chunking {
199 SingleRange {
202 start: Bound,
204 end: Bound,
206 },
207 DeltaList {
220 deltas: Vec<Bound>,
222 },
223}
224
225#[derive(Debug, Clone, PartialEq)]
241pub struct PartitionSpec {
242 pub chunking: Chunking,
244 pub window: Option<(Bound, Bound)>,
246 pub order: PartitionOrder,
248}
249
250impl PartitionSpec {
251 pub fn single_range(start: Bound, end: Bound) -> Self {
253 Self {
254 chunking: Chunking::SingleRange { start, end },
255 window: None,
256 order: PartitionOrder::Unchanged,
257 }
258 }
259
260 pub fn delta_list(deltas: Vec<Bound>) -> Self {
262 Self {
263 chunking: Chunking::DeltaList { deltas },
264 window: None,
265 order: PartitionOrder::Unchanged,
266 }
267 }
268}
269
270#[derive(Debug, Clone, Copy, PartialEq)]
276pub struct Partition {
277 pub idx: u64,
280 pub count: u64,
286 pub start_ord: u64,
288 pub end_ord: u64,
290 pub start_pct: f64,
292 pub end_pct: f64,
294 pub base_extent: u64,
298}
299
300impl Partition {
301 #[inline]
303 pub fn cardinality(&self) -> u64 {
304 self.end_ord - self.start_ord
305 }
306}
307
308pub fn parse(input: &str) -> Result<PartitionSpec, String> {
348 let mut tokens: Vec<&str> = input.split_whitespace().collect();
352 if tokens.is_empty() {
353 return Err(format!("empty spec: `{input}`"));
354 }
355 let mut order = PartitionOrder::Unchanged;
359 if tokens.len() >= 2 {
360 let last = *tokens.last().unwrap();
361 if !last.is_empty()
362 && last.chars().all(|c| c.is_ascii_alphabetic() || c == '_')
363 && last != "in"
364 {
365 order = match last {
366 "unchanged" => PartitionOrder::Unchanged,
367 "smallest_first" => PartitionOrder::SmallestFirst,
368 "largest_first" => PartitionOrder::LargestFirst,
369 "random" => PartitionOrder::Random,
370 "ascending" => {
375 return Err("`ascending`: partition order sorts key on partition SIZE, \
376 not ordinal position (position order is always the \
377 generation order — that's `unchanged`). Spell it \
378 `smallest_first`"
379 .into());
380 }
381 "descending" => {
382 return Err(
383 "`descending`: partition order sorts key on partition SIZE, \
384 not ordinal position (position order is always the \
385 generation order — that's `unchanged`). Spell it \
386 `largest_first`"
387 .into(),
388 );
389 }
390 other => {
391 return Err(format!(
392 "unknown order `{other}` — supported: unchanged, \
393 smallest_first, largest_first, random"
394 ));
395 }
396 };
397 tokens.pop();
398 }
399 }
400 let in_positions: Vec<usize> = tokens
403 .iter()
404 .enumerate()
405 .filter_map(|(i, t)| (*t == "in").then_some(i))
406 .collect();
407 let (chunk_tokens, window_tokens): (&[&str], Option<&[&str]>) = match in_positions.as_slice() {
408 [] => (&tokens[..], None),
409 [i] => {
410 if *i == 0 {
411 return Err(format!("`in` without a chunking spec before it: `{input}`"));
412 }
413 if *i == tokens.len() - 1 {
414 return Err(format!("`in` without a window range after it: `{input}`"));
415 }
416 (&tokens[..*i], Some(&tokens[*i + 1..]))
417 }
418 _ => {
419 return Err(format!(
420 "at most one `in <window>` clause is allowed: `{input}`"
421 ));
422 }
423 };
424 let window = match window_tokens {
425 None => None,
426 Some(wt) => Some(parse_window(&clean_part(wt), input)?),
427 };
428 let chunking = parse_chunking(&clean_part(chunk_tokens), input)?;
429 Ok(PartitionSpec {
430 chunking,
431 window,
432 order,
433 })
434}
435
436fn clean_part(tokens: &[&str]) -> String {
438 tokens
439 .concat()
440 .chars()
441 .filter(|c| !matches!(c, '[' | ']' | '(' | ')'))
442 .collect()
443}
444
445fn parse_window(cleaned: &str, input: &str) -> Result<(Bound, Bound), String> {
448 let Some((lhs, rhs)) = split_range(cleaned) else {
449 return Err(format!(
450 "the window after `in` must be a `start..end` range; got `{cleaned}` in `{input}`"
451 ));
452 };
453 let start = parse_bound(lhs)?;
454 let end = parse_bound(rhs)?;
455 if !start.is_sized() || !end.is_sized() {
456 return Err(format!(
457 "window endpoints must be sized values (percentage, fraction, or \
458 ordinal); got `{cleaned}` in `{input}`"
459 ));
460 }
461 Ok((start, end))
462}
463
464fn parse_chunking(cleaned: &str, input: &str) -> Result<Chunking, String> {
467 if cleaned.is_empty() {
468 return Err(format!("empty spec: `{input}`"));
469 }
470 if let Some((name, args)) = split_recipe(cleaned) {
473 let deltas = normalise_to_pct(&expand_recipe_weights(name, args)?)?;
474 return Ok(Chunking::DeltaList { deltas });
475 }
476 if cleaned == "..." {
479 return Err(
480 "the fill token `...` repeats the preceding delta until the extent \
481 is used up; it needs at least one delta before it (e.g. `1%,...`)"
482 .into(),
483 );
484 }
485 if cleaned.starts_with("*/") || cleaned.contains(",*/") {
490 return parse_delta_list(cleaned, input);
491 }
492 if cleaned.contains(',') {
496 return parse_delta_list(cleaned, input);
497 }
498 if let Some((lhs, rhs)) = split_range(cleaned) {
500 let start = parse_bound(lhs)?;
501 let end = parse_bound(rhs)?;
502 if !start.is_sized() || !end.is_sized() {
504 return Err(format!(
505 "`*`, `...`, `~`, and `*/N` are only valid inside a comma-separated \
506 delta list, not a `..` range; got `{input}`"
507 ));
508 }
509 return Ok(Chunking::SingleRange { start, end });
510 }
511 parse_delta_list(cleaned, input)
513}
514
515fn parse_delta_list(cleaned: &str, input: &str) -> Result<Chunking, String> {
522 let (head, star_tail) = if let Some(rest) = cleaned.strip_prefix("*/") {
525 ("", Some(rest))
526 } else if let Some(pos) = cleaned.find(",*/") {
527 (&cleaned[..pos], Some(&cleaned[pos + 3..]))
528 } else {
529 (cleaned, None)
530 };
531 let mut deltas: Vec<Bound> = Vec::new();
532 if !head.is_empty() {
533 for entry in head.split(',') {
534 if entry.is_empty() {
535 return Err(format!("empty entry in delta list: `{input}`"));
536 }
537 deltas.extend(parse_delta_entry(entry)?);
538 }
539 } else if star_tail.is_none() {
540 return Err(format!("empty spec: `{input}`"));
541 }
542 if let Some(tail) = star_tail {
543 deltas.push(parse_star_tail(tail)?);
544 }
545 let tail_count = deltas.iter().filter(|b| b.is_tail()).count();
546 if tail_count > 1 {
547 return Err(format!(
548 "at most one remainder token (`*`, `...`, `*/N`, or `*/recipe`) is \
549 allowed in a delta list; got {tail_count} in `{input}`"
550 ));
551 }
552 if let Some(pos) = deltas
553 .iter()
554 .position(|b| matches!(b, Bound::Fill | Bound::StarSplit(_) | Bound::StarShaped(_)))
555 {
556 if pos != deltas.len() - 1 {
557 return Err(format!(
558 "`{}` consumes the rest of the extent and must be the last entry \
559 in the delta list; got `{input}`",
560 deltas[pos]
561 ));
562 }
563 if matches!(deltas[pos], Bound::Fill) {
564 if pos == 0 {
565 return Err(
566 "the fill token `...` repeats the preceding delta until the extent \
567 is used up; it needs at least one delta before it (e.g. `1%,...`)"
568 .into(),
569 );
570 }
571 if matches!(deltas[pos - 1], Bound::Gap(_)) {
572 return Err(format!(
573 "`...` after a gap would emit nothing — the fill token repeats \
574 the immediately preceding delta. Put a sized delta before `...`, \
575 in `{input}`"
576 ));
577 }
578 }
579 }
580 if !deltas.iter().any(|b| b.is_sized() || b.is_tail()) {
582 return Err(format!(
583 "spec emits no partitions — every entry is a gap: `{input}`"
584 ));
585 }
586 Ok(Chunking::DeltaList { deltas })
587}
588
589fn parse_delta_entry(raw: &str) -> Result<Vec<Bound>, String> {
593 if let Some(rest) = raw.strip_prefix('~') {
595 if let Some((_, rep)) = rest.split_once('x')
596 && !rep.is_empty()
597 && rep.chars().all(|c| c.is_ascii_digit())
598 {
599 return Err(format!(
600 "`~{rest}`: repetition does not apply to gaps — size the gap \
601 directly (adjacent gaps are one gap)"
602 ));
603 }
604 let inner = parse_bound(rest)?;
605 if !inner.is_sized() {
606 return Err(format!(
607 "`~{rest}`: a gap requires a sized value (percentage, fraction, or \
608 ordinal). To ignore the trailing remainder, just end the list \
609 without a tail token — under-summing lists drop the gap"
610 ));
611 }
612 return Ok(vec![Bound::Gap(Box::new(inner))]);
613 }
614 if let Some((lhs, rhs)) = raw.split_once('x')
616 && !lhs.is_empty()
617 && !rhs.is_empty()
618 && rhs.chars().all(|c| c.is_ascii_digit())
619 {
620 let n: u64 = rhs
621 .parse()
622 .map_err(|_| format!("invalid repetition count in `{raw}`"))?;
623 if n == 0 {
624 return Err(format!("`{raw}`: the repetition count must be >= 1"));
625 }
626 let b = parse_bound(lhs)?;
627 if !b.is_sized() {
628 return Err(format!(
629 "`{raw}`: repetition applies to sized deltas (percentage, \
630 fraction, or ordinal) only"
631 ));
632 }
633 return Ok(vec![b; n as usize]);
634 }
635 Ok(vec![parse_bound(raw)?])
636}
637
638fn parse_star_tail(divisor: &str) -> Result<Bound, String> {
641 if !divisor.contains(':') && divisor.contains(',') {
645 let count = divisor.split(',').next().unwrap_or(divisor);
646 return Err(format!(
647 "`*/{count}` consumes the rest of the extent and must be the last \
648 entry in the delta list; got trailing entries after it"
649 ));
650 }
651 if let Some((name, args)) = split_recipe(divisor) {
652 if name == "linear" {
653 return Err(format!(
654 "`*/linear:{args}`: spell an equal-count remainder split as \
655 `*/{args}` — `*/N` is the canonical form"
656 ));
657 }
658 let weights = normalise_weights(&expand_recipe_weights(name, args)?)?;
659 return Ok(Bound::StarShaped(weights));
660 }
661 if divisor.contains('%') || divisor.contains('.') {
662 return Err(format!(
663 "`*/{divisor}`: the divisor after `*/` is a chunk count and must be a \
664 bare integer (e.g. `*/10` = remainder in 10 equal chunks). For \
665 fixed-size chunks repeated until the extent is used up, spell the \
666 size as a delta followed by the fill token: `{divisor},...`"
667 ));
668 }
669 let n: u64 = divisor.parse().map_err(|_| {
670 format!("invalid remainder split `*/{divisor}`: expected `*/N` with integer N >= 1, or `*/recipe:args`")
671 })?;
672 if n == 0 {
673 return Err("`*/0`: the remainder split count must be >= 1".into());
674 }
675 Ok(Bound::StarSplit(n))
676}
677
678fn split_recipe(s: &str) -> Option<(&str, &str)> {
682 let colon = s.find(':')?;
683 let name = &s[..colon];
684 if name.is_empty() {
685 return None;
686 }
687 if !name.chars().all(|c| c.is_ascii_alphabetic() || c == '_') {
688 return None;
689 }
690 Some((name, &s[colon + 1..]))
691}
692
693fn split_range(s: &str) -> Option<(&str, &str)> {
696 s.find("..").map(|idx| (&s[..idx], &s[idx + 2..]))
697}
698
699fn parse_bound(raw: &str) -> Result<Bound, String> {
703 let s = raw.trim();
704 if s.is_empty() {
705 return Err("empty bound".into());
706 }
707 if s == "..." {
709 return Ok(Bound::Fill);
710 }
711 if s == "*" || s == "*%" {
718 return Ok(Bound::Star);
719 }
720 if let Some(num) = s.strip_suffix('%') {
722 let value: f64 = num
723 .trim()
724 .parse()
725 .map_err(|_| format!("invalid percentage `{raw}`: expected a number before `%`"))?;
726 if !(0.0..=100.0).contains(&value) {
727 return Err(format!(
728 "percentage `{raw}` out of range — must be in [0%, 100%]"
729 ));
730 }
731 return Ok(Bound::Pct(value));
732 }
733 if s.contains('.') {
735 let value: f64 = s.parse().map_err(|_| format!("invalid decimal `{raw}`"))?;
736 if !(0.0..=1.0).contains(&value) {
737 return Err(format!(
738 "decimal `{raw}` is ambiguous — fractions must be in [0.0, 1.0]; \
739 did you mean `{}%` (percentage), `0.0{}` (fraction), or `{}` (literal ordinal)?",
740 value,
741 raw.replace('.', ""),
742 raw.replace('.', ""),
743 ));
744 }
745 return Ok(Bound::Frac(value));
746 }
747 let value: u64 = s
749 .parse()
750 .map_err(|_| format!("invalid number `{raw}`: expected an integer ordinal, decimal fraction (0.x), or `N%` percentage"))?;
751 Ok(Bound::Ord(value))
752}
753
754fn expand_recipe_weights(name: &str, args: &str) -> Result<Vec<f64>, String> {
763 let parts: Vec<&str> = args.split(',').map(|s| s.trim()).collect();
764 let weights = match name {
765 "linear" => recipe_linear(&parts)?,
766 "ratios" => recipe_ratios(&parts)?,
767 "mul" => recipe_mul(&parts)?,
768 "bin" => recipe_bin(&parts)?,
769 "fib" => recipe_fib(&parts)?,
770 "ln" => recipe_ln(&parts)?,
771 "geom" => recipe_geom(&parts)?,
772 "zipf" => recipe_zipf(&parts)?,
773 "pareto" => recipe_pareto(&parts)?,
774 "front_heavy" => recipe_front_heavy(&parts)?,
775 "back_heavy" => recipe_back_heavy(&parts)?,
776 _ => {
777 return Err(format!(
778 "unknown recipe `{name}` — supported: linear, ratios, mul, bin, fib, ln, \
779 geom, zipf, pareto, front_heavy, back_heavy"
780 ));
781 }
782 };
783 Ok(weights)
784}
785
786fn parse_u64_arg(arg: &str, ctx: &str) -> Result<u64, String> {
787 arg.parse()
788 .map_err(|_| format!("invalid integer arg `{arg}` for {ctx}"))
789}
790
791fn parse_f64_arg(arg: &str, ctx: &str) -> Result<f64, String> {
792 arg.parse()
793 .map_err(|_| format!("invalid number arg `{arg}` for {ctx}"))
794}
795
796fn recipe_linear(args: &[&str]) -> Result<Vec<f64>, String> {
797 if args.len() != 1 {
798 return Err(format!(
799 "linear:N expects exactly 1 argument (the partition count); got {}",
800 args.len()
801 ));
802 }
803 let n = parse_u64_arg(args[0], "linear")?;
804 if n == 0 {
805 return Err("linear:N requires N >= 1".into());
806 }
807 Ok(vec![1.0; n as usize])
808}
809
810fn recipe_ratios(args: &[&str]) -> Result<Vec<f64>, String> {
811 if args.is_empty() {
812 return Err("ratios:a,b,c,... requires at least one weight".into());
813 }
814 args.iter().map(|a| parse_f64_arg(a, "ratios")).collect()
815}
816
817fn recipe_mul(args: &[&str]) -> Result<Vec<f64>, String> {
818 let (start, ratio) = match args.len() {
819 1 => (1.0, parse_f64_arg(args[0], "mul")?),
820 2 => (
821 parse_f64_arg(args[0], "mul")?,
822 parse_f64_arg(args[1], "mul")?,
823 ),
824 n => {
825 return Err(format!(
826 "mul:R or mul:S,R expects 1 or 2 arguments; got {n}"
827 ));
828 }
829 };
830 if start <= 0.0 {
831 return Err(format!("mul:S,R requires S > 0; got {start}"));
832 }
833 if ratio <= 0.0 {
834 return Err(format!("mul:R requires R > 0; got {ratio}"));
835 }
836 const HARD_CAP: usize = 64;
845 let mut weights = Vec::with_capacity(HARD_CAP);
846 let mut current = start;
847 for _ in 0..HARD_CAP {
848 if !current.is_finite() || current <= 0.0 {
849 break;
850 }
851 weights.push(current);
852 if ratio < 1.0 && current < start * 0.001 {
853 break;
854 }
855 current *= ratio;
856 if ratio >= 1.0 && current >= start * 1000.0 {
857 if current.is_finite() {
860 weights.push(current);
861 }
862 break;
863 }
864 }
865 if weights.is_empty() {
866 return Err(format!(
867 "mul:{start},{ratio} produced no terms — pick a larger start"
868 ));
869 }
870 Ok(weights)
871}
872
873fn recipe_bin(args: &[&str]) -> Result<Vec<f64>, String> {
874 if args.len() != 1 {
875 return Err(format!(
876 "bin:N expects exactly 1 argument (the term count); got {}",
877 args.len()
878 ));
879 }
880 let n = parse_u64_arg(args[0], "bin")?;
881 if n == 0 {
882 return Err("bin:N requires N >= 1".into());
883 }
884 let degree = n - 1;
886 let mut coeffs = vec![1.0f64; n as usize];
887 for k in 1..=degree {
888 coeffs[k as usize] = coeffs[(k - 1) as usize] * ((degree - k + 1) as f64) / (k as f64);
889 }
890 Ok(coeffs)
891}
892
893fn recipe_fib(args: &[&str]) -> Result<Vec<f64>, String> {
894 if args.len() != 1 {
895 return Err(format!(
896 "fib:N expects exactly 1 argument (the term count); got {}",
897 args.len()
898 ));
899 }
900 let n = parse_u64_arg(args[0], "fib")?;
901 if n == 0 {
902 return Err("fib:N requires N >= 1".into());
903 }
904 let mut weights = Vec::with_capacity(n as usize);
907 let (mut a, mut b) = (1u64, 2u64);
908 for _ in 0..n {
909 weights.push(a as f64);
910 let next = a.saturating_add(b);
911 a = b;
912 b = next;
913 }
914 Ok(weights)
915}
916
917fn recipe_ln(args: &[&str]) -> Result<Vec<f64>, String> {
918 if args.len() != 1 {
919 return Err(format!(
920 "ln:N expects exactly 1 argument (the term count); got {}",
921 args.len()
922 ));
923 }
924 let n = parse_u64_arg(args[0], "ln")?;
925 if n == 0 {
926 return Err("ln:N requires N >= 1".into());
927 }
928 Ok((1..=n).map(|i| (1.0 + i as f64).ln()).collect())
929}
930
931fn recipe_geom(args: &[&str]) -> Result<Vec<f64>, String> {
932 if args.len() != 2 {
933 return Err(format!(
934 "geom:N,R expects exactly 2 arguments; got {}",
935 args.len()
936 ));
937 }
938 let n = parse_u64_arg(args[0], "geom")?;
939 let r = parse_f64_arg(args[1], "geom")?;
940 if n == 0 {
941 return Err("geom:N,R requires N >= 1".into());
942 }
943 if r <= 0.0 {
944 return Err(format!("geom:N,R requires R > 0; got {r}"));
945 }
946 let mut weights = Vec::with_capacity(n as usize);
947 let mut current = 1.0;
948 for _ in 0..n {
949 weights.push(current);
950 current *= r;
951 }
952 Ok(weights)
953}
954
955fn recipe_zipf(args: &[&str]) -> Result<Vec<f64>, String> {
956 if args.len() != 2 {
957 return Err(format!(
958 "zipf:s,N expects exactly 2 arguments; got {}",
959 args.len()
960 ));
961 }
962 let s = parse_f64_arg(args[0], "zipf")?;
963 let n = parse_u64_arg(args[1], "zipf")?;
964 if s <= 0.0 {
965 return Err(format!("zipf:s,N requires s > 0; got {s}"));
966 }
967 if n == 0 {
968 return Err("zipf:s,N requires N >= 1".into());
969 }
970 Ok((1..=n).map(|i| 1.0 / (i as f64).powf(s)).collect())
971}
972
973fn recipe_pareto(args: &[&str]) -> Result<Vec<f64>, String> {
974 if args.len() != 2 {
975 return Err(format!(
976 "pareto:alpha,N expects exactly 2 arguments; got {}",
977 args.len()
978 ));
979 }
980 let alpha = parse_f64_arg(args[0], "pareto")?;
981 let n = parse_u64_arg(args[1], "pareto")?;
982 if alpha <= 0.0 {
983 return Err(format!("pareto:alpha,N requires alpha > 0; got {alpha}"));
984 }
985 if n == 0 {
986 return Err("pareto:alpha,N requires N >= 1".into());
987 }
988 Ok((1..=n).map(|i| (1.0 / i as f64).powf(alpha)).collect())
989}
990
991fn recipe_front_heavy(args: &[&str]) -> Result<Vec<f64>, String> {
992 if args.len() != 1 {
993 return Err(format!(
994 "front_heavy:N expects exactly 1 argument; got {}",
995 args.len()
996 ));
997 }
998 let n = parse_u64_arg(args[0], "front_heavy")?;
999 if n == 0 {
1000 return Err("front_heavy:N requires N >= 1".into());
1001 }
1002 Ok((1..=n).rev().map(|i| i as f64).collect())
1003}
1004
1005fn recipe_back_heavy(args: &[&str]) -> Result<Vec<f64>, String> {
1006 if args.len() != 1 {
1007 return Err(format!(
1008 "back_heavy:N expects exactly 1 argument; got {}",
1009 args.len()
1010 ));
1011 }
1012 let n = parse_u64_arg(args[0], "back_heavy")?;
1013 if n == 0 {
1014 return Err("back_heavy:N requires N >= 1".into());
1015 }
1016 Ok((1..=n).map(|i| i as f64).collect())
1017}
1018
1019fn normalise_weights(weights: &[f64]) -> Result<Vec<f64>, String> {
1022 if weights.iter().any(|w| !w.is_finite() || *w < 0.0) {
1023 return Err("recipe produced non-finite or negative weights".into());
1024 }
1025 let sum: f64 = weights.iter().sum();
1026 if sum <= 0.0 {
1027 return Err("recipe produced zero total weight".into());
1028 }
1029 Ok(weights.iter().map(|w| w / sum * 100.0).collect())
1030}
1031
1032fn normalise_to_pct(weights: &[f64]) -> Result<Vec<Bound>, String> {
1035 Ok(normalise_weights(weights)?
1036 .into_iter()
1037 .map(Bound::Pct)
1038 .collect())
1039}
1040
1041pub fn resolve(
1080 spec: &PartitionSpec,
1081 base_start: u64,
1082 base_end: u64,
1083) -> Result<Vec<Partition>, String> {
1084 if base_end < base_start {
1085 return Err(format!(
1086 "resolve: base_end ({base_end}) < base_start ({base_start})"
1087 ));
1088 }
1089 let base_extent = base_end - base_start;
1090 let (dom_start, dom_end) = match &spec.window {
1092 None => (base_start, base_end),
1093 Some((ws, we)) => {
1094 let s = ws
1095 .resolve_against(base_start, base_end)
1096 .expect("window bounds are sized (checked at parse time)");
1097 let e = we
1098 .resolve_against(base_start, base_end)
1099 .expect("window bounds are sized (checked at parse time)");
1100 if e < s {
1101 return Err(format!(
1102 "window `in {ws}..{we}` is empty or reversed against \
1103 base=[{base_start}..{base_end}): start={s}, end={e}"
1104 ));
1105 }
1106 (s, e)
1107 }
1108 };
1109 let dom_extent = dom_end - dom_start;
1110 let frame = Frame {
1113 base_start,
1114 base_extent,
1115 };
1116 let mut partitions = match &spec.chunking {
1117 Chunking::SingleRange { start, end } => {
1118 let start_ord = start
1119 .resolve_against(dom_start, dom_end)
1120 .expect("tail tokens not allowed in SingleRange (checked at parse time)");
1121 let end_ord = end
1122 .resolve_against(dom_start, dom_end)
1123 .expect("tail tokens not allowed in SingleRange (checked at parse time)");
1124 if end_ord < start_ord {
1125 return Err(format!(
1126 "resolved range is empty or reversed: start={start_ord}, end={end_ord} \
1127 (spec start={start}, end={end}, base=[{dom_start}..{dom_end}))"
1128 ));
1129 }
1130 if start_ord == end_ord {
1140 return Err(format!(
1141 "range `{start}..{end}` resolves to zero ordinals \
1142 ([{start_ord}..{end_ord}) against base=[{dom_start}..{dom_end})) — \
1143 the slice rounds to nothing at this extent; widen the range \
1144 or use a larger extent"
1145 ));
1146 }
1147 vec![frame.partition(0, start_ord, end_ord)]
1148 }
1149 Chunking::DeltaList { deltas } => {
1150 resolve_delta_list(deltas, dom_start, dom_end, dom_extent, frame)?
1151 }
1152 };
1153 let count = partitions.len() as u64;
1155 for p in &mut partitions {
1156 p.count = count;
1157 }
1158 apply_order(&mut partitions, spec);
1159 Ok(partitions)
1160}
1161
1162#[derive(Clone, Copy)]
1166struct Frame {
1167 base_start: u64,
1168 base_extent: u64,
1169}
1170
1171impl Frame {
1172 fn partition(&self, idx: u64, start_ord: u64, end_ord: u64) -> Partition {
1173 Partition {
1176 count: 0,
1177 idx,
1178 start_ord,
1179 end_ord,
1180 start_pct: pct_of(start_ord, self.base_start, self.base_extent),
1181 end_pct: pct_of(end_ord, self.base_start, self.base_extent),
1182 base_extent: self.base_extent,
1183 }
1184 }
1185}
1186
1187fn apply_order(partitions: &mut [Partition], spec: &PartitionSpec) {
1195 match spec.order {
1196 PartitionOrder::Unchanged => {}
1197 PartitionOrder::SmallestFirst => {
1198 partitions.sort_by_key(|p| p.cardinality());
1199 }
1200 PartitionOrder::LargestFirst => {
1201 partitions.sort_by_key(|p| std::cmp::Reverse(p.cardinality()));
1202 }
1203 PartitionOrder::Random => {
1204 let mut state = xxhash_rust::xxh3::xxh3_64(format!("{spec:?}").as_bytes());
1205 for i in (1..partitions.len()).rev() {
1206 let j = (splitmix64(&mut state) % (i as u64 + 1)) as usize;
1207 partitions.swap(i, j);
1208 }
1209 }
1210 }
1211}
1212
1213fn splitmix64(state: &mut u64) -> u64 {
1216 *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
1217 let mut z = *state;
1218 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1219 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1220 z ^ (z >> 31)
1221}
1222
1223fn resolve_delta_list(
1224 deltas: &[Bound],
1225 dom_start: u64,
1226 dom_end: u64,
1227 extent: u64,
1228 frame: Frame,
1229) -> Result<Vec<Partition>, String> {
1230 let non_tail_exact: f64 = deltas
1241 .iter()
1242 .filter(|b| !b.is_tail())
1243 .map(|b| delta_exact_ordinals(b, extent))
1244 .sum();
1245 let tolerance = 1e-6 * (extent as f64).max(1.0);
1248 if non_tail_exact > extent as f64 + tolerance {
1249 return Err(format!(
1250 "delta list sums to {} ordinals, exceeding the cursor's extent {extent}; \
1251 trim the list or use a `*` remainder to absorb the overflow",
1252 non_tail_exact.round() as u64
1253 ));
1254 }
1255 let mut partitions: Vec<Partition> = Vec::with_capacity(deltas.len());
1256 let mut cursor = dom_start;
1257 let mut exact_pos = 0.0f64;
1259 let push = |partitions: &mut Vec<Partition>, start: u64, end: u64| {
1260 let idx = partitions.len() as u64;
1261 partitions.push(frame.partition(idx, start, end));
1262 };
1263 let boundary = |exact_pos: f64| -> u64 { (dom_start + exact_pos.round() as u64).min(dom_end) };
1264 for (i, delta) in deltas.iter().enumerate() {
1265 match delta {
1266 Bound::Star => {
1267 exact_pos += extent as f64 - non_tail_exact;
1269 let next = boundary(exact_pos);
1270 push(&mut partitions, cursor, next);
1271 cursor = next;
1272 }
1273 Bound::Fill => {
1274 let chunk = delta_exact_ordinals(&deltas[i - 1], extent);
1280 if chunk < 1.0 {
1281 return Err(format!(
1282 "fill token `...` would repeat a delta of less than one \
1283 ordinal (`{}` resolves to {chunk:.3} ordinals against \
1284 extent {extent})",
1285 deltas[i - 1]
1286 ));
1287 }
1288 while cursor < dom_end {
1289 exact_pos += chunk;
1290 let next = boundary(exact_pos);
1291 push(&mut partitions, cursor, next);
1292 cursor = next;
1293 }
1294 }
1295 Bound::StarSplit(n) => {
1296 let remainder = dom_end - cursor;
1300 if remainder == 0 {
1301 return Err(format!(
1302 "`*/{n}` has no remainder to divide — the preceding deltas \
1303 already cover the extent {extent}"
1304 ));
1305 }
1306 if *n > remainder {
1307 return Err(format!(
1308 "`*/{n}` cannot divide a remainder of {remainder} ordinals \
1309 into {n} non-empty partitions"
1310 ));
1311 }
1312 for (s, e) in split_evenly(cursor, dom_end, *n) {
1313 push(&mut partitions, s, e);
1314 }
1315 cursor = dom_end;
1316 }
1317 Bound::StarShaped(weights) => {
1318 let remainder = dom_end - cursor;
1323 if remainder == 0 {
1324 return Err(format!(
1325 "`*/<recipe>` has no remainder to divide — the preceding \
1326 deltas already cover the extent {extent}"
1327 ));
1328 }
1329 let start = cursor;
1330 let mut cum = 0.0f64;
1331 for w in weights {
1332 cum += w;
1333 let next =
1334 (start + ((cum / 100.0) * remainder as f64).round() as u64).min(dom_end);
1335 if next == cursor {
1336 return Err(format!(
1337 "`*/<recipe>` produces an empty partition — weight \
1338 {w:.3}% of a {remainder}-ordinal remainder rounds to \
1339 zero ordinals; use fewer/coarser weights or a larger \
1340 remainder"
1341 ));
1342 }
1343 push(&mut partitions, cursor, next);
1344 cursor = next;
1345 }
1346 exact_pos += remainder as f64;
1347 }
1348 Bound::Gap(inner) => {
1349 exact_pos += delta_exact_ordinals(inner, extent);
1353 cursor = boundary(exact_pos);
1354 }
1355 other => {
1356 exact_pos += delta_exact_ordinals(other, extent);
1357 let next = boundary(exact_pos);
1358 push(&mut partitions, cursor, next);
1359 cursor = next;
1360 }
1361 }
1362 }
1363 debug_assert!(cursor <= dom_end);
1367 Ok(partitions)
1368}
1369
1370fn delta_exact_ordinals(b: &Bound, extent: u64) -> f64 {
1376 match b {
1377 Bound::Pct(p) => (p / 100.0) * extent as f64,
1378 Bound::Frac(f) => f * extent as f64,
1379 Bound::Ord(o) => *o as f64,
1380 Bound::Gap(inner) => delta_exact_ordinals(inner, extent),
1381 Bound::Star | Bound::Fill | Bound::StarSplit(_) | Bound::StarShaped(_) => {
1382 unreachable!("tail tokens handled separately")
1383 }
1384 }
1385}
1386
1387pub fn subdivide_partition(p: &Partition, n: u64) -> Result<Vec<Partition>, String> {
1401 let card = p.cardinality();
1402 if n == 0 {
1403 return Err("subdivide(p, 0): the sub-partition count must be >= 1".into());
1404 }
1405 if n > card {
1406 return Err(format!(
1407 "subdivide(p, {n}): cannot divide partition #{} of {card} ordinals \
1408 into {n} non-empty sub-partitions",
1409 p.idx
1410 ));
1411 }
1412 let pct_at = |ord: u64| -> f64 {
1413 p.start_pct + (ord - p.start_ord) as f64 / card as f64 * (p.end_pct - p.start_pct)
1414 };
1415 Ok(split_evenly(p.start_ord, p.end_ord, n)
1416 .into_iter()
1417 .enumerate()
1418 .map(|(i, (start_ord, end_ord))| Partition {
1419 idx: i as u64,
1420 count: n,
1421 start_ord,
1422 end_ord,
1423 start_pct: pct_at(start_ord),
1424 end_pct: pct_at(end_ord),
1425 base_extent: p.base_extent,
1426 })
1427 .collect())
1428}
1429
1430pub fn split_evenly(start_ord: u64, end_ord: u64, n: u64) -> Vec<(u64, u64)> {
1440 debug_assert!(n >= 1, "split_evenly requires n >= 1");
1441 debug_assert!(end_ord >= start_ord);
1442 let span = (end_ord - start_ord) as u128;
1443 let n_wide = n as u128;
1444 let boundary =
1445 |i: u64| -> u64 { start_ord + ((i as u128 * span + n_wide / 2) / n_wide) as u64 };
1446 (0..n).map(|i| (boundary(i), boundary(i + 1))).collect()
1447}
1448
1449#[inline]
1450fn pct_of(ordinal: u64, base_start: u64, extent: u64) -> f64 {
1451 if extent == 0 {
1452 0.0
1453 } else {
1454 (ordinal - base_start) as f64 * 100.0 / extent as f64
1455 }
1456}
1457
1458impl ReflectedValue for Partition {
1470 fn type_name(&self) -> &str {
1471 "Partition"
1472 }
1473
1474 fn display(&self) -> String {
1475 format!(
1476 "Partition({}/{} [{}..{}) [{:.2}%..{:.2}%))",
1477 self.idx, self.count, self.start_ord, self.end_ord, self.start_pct, self.end_pct,
1478 )
1479 }
1480
1481 fn to_json_value(&self) -> serde_json::Value {
1482 serde_json::json!({
1483 "idx": self.idx,
1484 "count": self.count,
1485 "start_ord": self.start_ord,
1486 "end_ord": self.end_ord,
1487 "start_pct": self.start_pct,
1488 "end_pct": self.end_pct,
1489 "base_extent": self.base_extent,
1490 "cardinality": self.cardinality(),
1491 })
1492 }
1493
1494 fn as_any(&self) -> &dyn std::any::Any {
1495 self
1496 }
1497
1498 fn clone_reflected(&self) -> Box<dyn ReflectedValue> {
1499 Box::new(*self)
1500 }
1501}
1502
1503impl ReflectedValue for PartitionSpec {
1504 fn type_name(&self) -> &str {
1505 "PartitionSpec"
1506 }
1507
1508 fn display(&self) -> String {
1509 let chunking = match &self.chunking {
1510 Chunking::SingleRange { start, end } => format!("{start}..{end}"),
1511 Chunking::DeltaList { deltas } => {
1512 let parts: Vec<String> = deltas.iter().map(|b| b.to_string()).collect();
1513 parts.join(",")
1514 }
1515 };
1516 let window = match &self.window {
1517 Some((s, e)) => format!(" in {s}..{e}"),
1518 None => String::new(),
1519 };
1520 let order = match self.order {
1521 PartitionOrder::Unchanged => String::new(),
1522 o => format!(" {o}"),
1523 };
1524 format!("PartitionSpec({chunking}{window}{order})")
1525 }
1526
1527 fn to_json_value(&self) -> serde_json::Value {
1528 serde_json::Value::String(self.display())
1529 }
1530
1531 fn as_any(&self) -> &dyn std::any::Any {
1532 self
1533 }
1534
1535 fn clone_reflected(&self) -> Box<dyn ReflectedValue> {
1536 Box::new(self.clone())
1537 }
1538}
1539
1540#[derive(Debug, Clone)]
1546pub struct PartitionList(pub Arc<Vec<Partition>>);
1547
1548impl PartitionList {
1549 pub fn new(partitions: Vec<Partition>) -> Self {
1551 Self(Arc::new(partitions))
1552 }
1553
1554 pub fn len(&self) -> usize {
1556 self.0.len()
1557 }
1558
1559 pub fn is_empty(&self) -> bool {
1561 self.0.is_empty()
1562 }
1563
1564 pub fn as_slice(&self) -> &[Partition] {
1566 &self.0
1567 }
1568}
1569
1570impl ReflectedValue for PartitionList {
1571 fn type_name(&self) -> &str {
1572 "PartitionList"
1573 }
1574
1575 fn display(&self) -> String {
1576 let parts: Vec<String> = self
1577 .0
1578 .iter()
1579 .map(|p| format!("[{}..{})", p.start_ord, p.end_ord))
1580 .collect();
1581 format!("PartitionList[{}]={}", self.0.len(), parts.join(","))
1582 }
1583
1584 fn to_json_value(&self) -> serde_json::Value {
1585 serde_json::Value::Array(self.0.iter().map(|p| p.to_json_value()).collect())
1586 }
1587
1588 fn as_any(&self) -> &dyn std::any::Any {
1589 self
1590 }
1591
1592 fn clone_reflected(&self) -> Box<dyn ReflectedValue> {
1593 Box::new(self.clone())
1594 }
1595}
1596
1597impl Value {
1601 pub fn from_partition(p: Partition) -> Self {
1603 Value::Ext(Box::new(p))
1604 }
1605
1606 pub fn from_partition_spec(s: PartitionSpec) -> Self {
1608 Value::Ext(Box::new(s))
1609 }
1610
1611 pub fn from_partition_list(parts: Vec<Partition>) -> Self {
1616 Value::Ext(Box::new(PartitionList::new(parts)))
1617 }
1618
1619 pub fn as_partition(&self) -> Option<&Partition> {
1622 match self {
1623 Value::Ext(b) => b.as_any().downcast_ref::<Partition>(),
1624 _ => None,
1625 }
1626 }
1627
1628 pub fn as_partition_spec(&self) -> Option<&PartitionSpec> {
1631 match self {
1632 Value::Ext(b) => b.as_any().downcast_ref::<PartitionSpec>(),
1633 _ => None,
1634 }
1635 }
1636
1637 pub fn as_partition_list(&self) -> Option<&PartitionList> {
1640 match self {
1641 Value::Ext(b) => b.as_any().downcast_ref::<PartitionList>(),
1642 _ => None,
1643 }
1644 }
1645}
1646
1647#[cfg(test)]
1652mod tests {
1653 use super::*;
1654
1655 #[test]
1658 fn parse_bound_percentage() {
1659 assert_eq!(parse_bound("53%").unwrap(), Bound::Pct(53.0));
1660 assert_eq!(parse_bound("0%").unwrap(), Bound::Pct(0.0));
1661 assert_eq!(parse_bound("100%").unwrap(), Bound::Pct(100.0));
1662 assert_eq!(parse_bound("0.5%").unwrap(), Bound::Pct(0.5));
1663 }
1664
1665 #[test]
1666 fn parse_bound_percentage_out_of_range_rejected() {
1667 assert!(parse_bound("101%").is_err());
1668 assert!(parse_bound("-1%").is_err());
1669 }
1670
1671 #[test]
1672 fn parse_bound_fraction() {
1673 assert_eq!(parse_bound("0.5").unwrap(), Bound::Frac(0.5));
1674 assert_eq!(parse_bound("0.0").unwrap(), Bound::Frac(0.0));
1675 assert_eq!(parse_bound("1.0").unwrap(), Bound::Frac(1.0));
1676 assert_eq!(parse_bound("0.123").unwrap(), Bound::Frac(0.123));
1677 }
1678
1679 #[test]
1680 fn parse_bound_fraction_out_of_range_rejected() {
1681 let err = parse_bound("1.5").unwrap_err();
1682 assert!(
1683 err.contains("ambiguous"),
1684 "diagnostic should explain: {err}"
1685 );
1686 }
1687
1688 #[test]
1689 fn parse_bound_literal_ordinal() {
1690 assert_eq!(parse_bound("0").unwrap(), Bound::Ord(0));
1691 assert_eq!(parse_bound("100").unwrap(), Bound::Ord(100));
1692 assert_eq!(parse_bound("999999").unwrap(), Bound::Ord(999_999));
1693 }
1694
1695 #[test]
1696 fn parse_bound_star_token() {
1697 assert_eq!(parse_bound("*").unwrap(), Bound::Star);
1698 assert_eq!(parse_bound("*%").unwrap(), Bound::Star);
1699 }
1700
1701 #[test]
1704 fn parse_form1_simple_pct() {
1705 let spec = parse("0..53%").unwrap();
1706 assert_eq!(
1707 spec,
1708 PartitionSpec::single_range(Bound::Ord(0), Bound::Pct(53.0))
1709 );
1710 }
1711
1712 #[test]
1713 fn parse_form1_brackets_tolerated() {
1714 let canonical = PartitionSpec::single_range(Bound::Ord(0), Bound::Pct(53.0));
1715 assert_eq!(parse("[0..53%]").unwrap(), canonical);
1716 assert_eq!(parse("[0..53%)").unwrap(), canonical);
1717 assert_eq!(parse("(0..53%]").unwrap(), canonical);
1718 }
1719
1720 #[test]
1721 fn parse_form1_fraction_form() {
1722 let spec = parse("0..0.53").unwrap();
1723 assert_eq!(
1724 spec,
1725 PartitionSpec::single_range(Bound::Ord(0), Bound::Frac(0.53))
1726 );
1727 }
1728
1729 #[test]
1730 fn parse_form1_literal_ordinals() {
1731 let spec = parse("100..1000").unwrap();
1732 assert_eq!(
1733 spec,
1734 PartitionSpec::single_range(Bound::Ord(100), Bound::Ord(1000))
1735 );
1736 }
1737
1738 #[test]
1739 fn parse_form1_mixed_literal_and_pct() {
1740 let spec = parse("100..50%").unwrap();
1741 assert_eq!(
1742 spec,
1743 PartitionSpec::single_range(Bound::Ord(100), Bound::Pct(50.0))
1744 );
1745 }
1746
1747 #[test]
1748 fn parse_form1_mixed_frac_and_literal() {
1749 let spec = parse("0.10..10000").unwrap();
1750 assert_eq!(
1751 spec,
1752 PartitionSpec::single_range(Bound::Frac(0.10), Bound::Ord(10000))
1753 );
1754 }
1755
1756 #[test]
1757 fn parse_form1_rejects_star() {
1758 assert!(parse("0..*").is_err());
1759 assert!(parse("*..50%").is_err());
1760 }
1761
1762 #[test]
1765 fn parse_form2_with_star() {
1766 let spec = parse("2%,10%,*%").unwrap();
1767 assert_eq!(
1768 spec,
1769 PartitionSpec::delta_list(vec![Bound::Pct(2.0), Bound::Pct(10.0), Bound::Star])
1770 );
1771 }
1772
1773 #[test]
1774 fn parse_form2_fraction_equivalent() {
1775 let spec = parse("0.02,0.10,*").unwrap();
1776 assert_eq!(
1777 spec,
1778 PartitionSpec::delta_list(vec![Bound::Frac(0.02), Bound::Frac(0.10), Bound::Star])
1779 );
1780 }
1781
1782 #[test]
1783 fn parse_form2_literal_deltas() {
1784 let spec = parse("1000,5000,*").unwrap();
1785 assert_eq!(
1786 spec,
1787 PartitionSpec::delta_list(vec![Bound::Ord(1000), Bound::Ord(5000), Bound::Star])
1788 );
1789 }
1790
1791 #[test]
1792 fn parse_form2_mixed_entries() {
1793 let spec = parse("1000,10%,*").unwrap();
1794 assert_eq!(
1795 spec,
1796 PartitionSpec::delta_list(vec![Bound::Ord(1000), Bound::Pct(10.0), Bound::Star])
1797 );
1798 }
1799
1800 #[test]
1801 fn parse_form2_short_list_no_star() {
1802 let spec = parse("20%,30%").unwrap();
1803 assert_eq!(
1804 spec,
1805 PartitionSpec::delta_list(vec![Bound::Pct(20.0), Bound::Pct(30.0)])
1806 );
1807 }
1808
1809 #[test]
1810 fn parse_form2_rejects_multiple_stars() {
1811 let err = parse("*,*").unwrap_err();
1812 assert!(err.contains("at most one"), "diagnostic: {err}");
1813 }
1814
1815 #[test]
1818 fn parse_form2_fill_token() {
1819 let spec = parse("90%,1%,...").unwrap();
1820 assert_eq!(
1821 spec,
1822 PartitionSpec::delta_list(vec![Bound::Pct(90.0), Bound::Pct(1.0), Bound::Fill])
1823 );
1824 }
1825
1826 #[test]
1827 fn parse_form2_star_split_token() {
1828 let spec = parse("90%,*/10").unwrap();
1829 assert_eq!(
1830 spec,
1831 PartitionSpec::delta_list(vec![Bound::Pct(90.0), Bound::StarSplit(10)])
1832 );
1833 }
1834
1835 #[test]
1836 fn parse_star_split_alone_is_whole_extent_split() {
1837 let spec = parse("*/16").unwrap();
1839 assert_eq!(spec, PartitionSpec::delta_list(vec![Bound::StarSplit(16)]));
1840 }
1841
1842 #[test]
1843 fn parse_fill_alone_rejected_with_hint() {
1844 let err = parse("...").unwrap_err();
1845 assert!(
1846 err.contains("preceding delta") || err.contains("before it"),
1847 "diagnostic: {err}"
1848 );
1849 }
1850
1851 #[test]
1852 fn parse_fill_first_in_list_rejected() {
1853 let err = parse("...,10%").unwrap_err();
1854 assert!(
1855 err.contains("before it") || err.contains("last entry"),
1856 "diagnostic: {err}"
1857 );
1858 }
1859
1860 #[test]
1861 fn parse_fill_not_last_rejected() {
1862 let err = parse("1%,...,10%").unwrap_err();
1863 assert!(err.contains("last entry"), "diagnostic: {err}");
1864 }
1865
1866 #[test]
1867 fn parse_star_split_not_last_rejected() {
1868 let err = parse("*/4,10%").unwrap_err();
1869 assert!(err.contains("last entry"), "diagnostic: {err}");
1870 }
1871
1872 #[test]
1873 fn parse_rejects_mixed_tail_tokens() {
1874 let err = parse("1%,*,...").unwrap_err();
1875 assert!(err.contains("at most one"), "diagnostic: {err}");
1876 let err = parse("1%,*,*/4").unwrap_err();
1877 assert!(err.contains("at most one"), "diagnostic: {err}");
1878 }
1879
1880 #[test]
1881 fn parse_star_split_pct_divisor_rejected_with_teaching_hint() {
1882 let err = parse("90%,*/1%").unwrap_err();
1886 assert!(err.contains("chunk count"), "diagnostic: {err}");
1887 assert!(
1888 err.contains("1%,..."),
1889 "diagnostic should teach the fill form: {err}"
1890 );
1891 let err = parse("90%,*/0.01").unwrap_err();
1892 assert!(err.contains("chunk count"), "diagnostic: {err}");
1893 }
1894
1895 #[test]
1896 fn parse_star_split_zero_rejected() {
1897 let err = parse("90%,*/0").unwrap_err();
1898 assert!(err.contains(">= 1"), "diagnostic: {err}");
1899 }
1900
1901 #[test]
1902 fn parse_form1_rejects_tail_tokens() {
1903 assert!(parse("0..*/4").is_err());
1904 assert!(parse("0....").is_err());
1907 }
1908
1909 fn deltas_only(spec: PartitionSpec) -> Vec<Bound> {
1912 match spec.chunking {
1913 Chunking::DeltaList { deltas } => deltas,
1914 other => panic!("expected DeltaList, got {other:?}"),
1915 }
1916 }
1917
1918 fn pcts_of(spec: PartitionSpec) -> Vec<f64> {
1919 deltas_only(spec)
1920 .into_iter()
1921 .map(|b| match b {
1922 Bound::Pct(p) => p,
1923 other => panic!("expected Pct, got {other:?}"),
1924 })
1925 .collect()
1926 }
1927
1928 #[test]
1929 fn recipe_linear_uniform_split() {
1930 let pcts = pcts_of(parse("linear:4").unwrap());
1931 assert_eq!(pcts.len(), 4);
1932 for p in &pcts {
1933 assert!((p - 25.0).abs() < 1e-9, "expected 25%, got {p}");
1934 }
1935 }
1936
1937 #[test]
1938 fn recipe_ratios_normalises_weights() {
1939 let pcts = pcts_of(parse("ratios:1,1,2").unwrap());
1940 assert_eq!(pcts.len(), 3);
1941 assert!((pcts[0] - 25.0).abs() < 1e-9);
1942 assert!((pcts[1] - 25.0).abs() < 1e-9);
1943 assert!((pcts[2] - 50.0).abs() < 1e-9);
1944 }
1945
1946 #[test]
1947 fn recipe_bin_5_is_five_terms_of_binomial_expansion() {
1948 let pcts = pcts_of(parse("bin:5").unwrap());
1950 assert_eq!(pcts.len(), 5);
1951 let expected = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
1952 for (i, e) in expected.iter().enumerate() {
1953 assert!(
1954 (pcts[i] - e * 100.0).abs() < 1e-9,
1955 "term {i}: {} vs {}",
1956 pcts[i],
1957 e * 100.0
1958 );
1959 }
1960 }
1961
1962 #[test]
1963 fn recipe_fib_7_uses_distinct_fibonacci() {
1964 let pcts = pcts_of(parse("fib:7").unwrap());
1966 assert_eq!(pcts.len(), 7);
1967 let expected_weights = [1.0, 2.0, 3.0, 5.0, 8.0, 13.0, 21.0];
1968 let sum: f64 = expected_weights.iter().sum();
1969 for (i, w) in expected_weights.iter().enumerate() {
1970 assert!((pcts[i] - w / sum * 100.0).abs() < 1e-9);
1971 }
1972 }
1973
1974 #[test]
1975 fn recipe_ln_5_log_spaced() {
1976 let pcts = pcts_of(parse("ln:5").unwrap());
1977 assert_eq!(pcts.len(), 5);
1978 for i in 1..pcts.len() {
1980 assert!(pcts[i] > pcts[i - 1], "ln:N should be monotonic");
1981 }
1982 let total: f64 = pcts.iter().sum();
1984 assert!((total - 100.0).abs() < 1e-9, "total: {total}");
1985 }
1986
1987 #[test]
1988 fn recipe_mul_decay_tail_off() {
1989 let pcts = pcts_of(parse("mul:0.5").unwrap());
1992 assert!(!pcts.is_empty());
1993 let total: f64 = pcts.iter().sum();
1994 assert!((total - 100.0).abs() < 1e-9, "total: {total}");
1995 assert!(pcts[0] > pcts[1]);
1997 }
1998
1999 #[test]
2000 fn recipe_mul_growth_caps_at_3_orders_of_magnitude() {
2001 let pcts = pcts_of(parse("mul:2").unwrap());
2004 assert!(!pcts.is_empty());
2005 assert!(pcts.len() < 64, "should terminate well before hard cap");
2006 let total: f64 = pcts.iter().sum();
2007 assert!((total - 100.0).abs() < 1e-9, "total: {total}");
2008 }
2009
2010 #[test]
2011 fn recipe_mul_with_start_and_ratio() {
2012 let pcts = pcts_of(parse("mul:5,0.5").unwrap());
2014 let total: f64 = pcts.iter().sum();
2015 assert!((total - 100.0).abs() < 1e-9, "total: {total}");
2016 }
2017
2018 #[test]
2019 fn recipe_geom_fixed_term_count() {
2020 let pcts = pcts_of(parse("geom:5,2").unwrap());
2021 assert_eq!(pcts.len(), 5);
2022 let expected_total: f64 = 31.0;
2024 let expected = [1.0, 2.0, 4.0, 8.0, 16.0];
2025 for (i, e) in expected.iter().enumerate() {
2026 assert!((pcts[i] - e / expected_total * 100.0).abs() < 1e-9);
2027 }
2028 }
2029
2030 #[test]
2031 fn recipe_front_heavy_declining() {
2032 let pcts = pcts_of(parse("front_heavy:4").unwrap());
2033 assert_eq!(pcts.len(), 4);
2034 for i in 1..pcts.len() {
2035 assert!(
2036 pcts[i] < pcts[i - 1],
2037 "front_heavy should be monotonic-declining"
2038 );
2039 }
2040 }
2041
2042 #[test]
2043 fn recipe_back_heavy_growing() {
2044 let pcts = pcts_of(parse("back_heavy:4").unwrap());
2045 assert_eq!(pcts.len(), 4);
2046 for i in 1..pcts.len() {
2047 assert!(
2048 pcts[i] > pcts[i - 1],
2049 "back_heavy should be monotonic-growing"
2050 );
2051 }
2052 }
2053
2054 #[test]
2055 fn recipe_unknown_name_rejected() {
2056 let err = parse("blorp:3").unwrap_err();
2057 assert!(err.contains("unknown recipe"), "diagnostic: {err}");
2058 assert!(
2059 err.contains("linear"),
2060 "should list supported recipes: {err}"
2061 );
2062 }
2063
2064 #[test]
2067 fn resolve_form1_percentage_against_extent() {
2068 let spec = parse("0..50%").unwrap();
2069 let parts = resolve(&spec, 0, 1000).unwrap();
2070 assert_eq!(parts.len(), 1);
2071 assert_eq!(parts[0].start_ord, 0);
2072 assert_eq!(parts[0].end_ord, 500);
2073 assert_eq!(parts[0].cardinality(), 500);
2074 }
2075
2076 #[test]
2077 fn resolve_form1_literal_ordinals() {
2078 let spec = parse("100..1000").unwrap();
2079 let parts = resolve(&spec, 0, 10000).unwrap();
2080 assert_eq!(parts[0].start_ord, 100);
2081 assert_eq!(parts[0].end_ord, 1000);
2082 assert_eq!(parts[0].cardinality(), 900);
2083 }
2084
2085 #[test]
2086 fn resolve_form1_mixed_literal_and_pct() {
2087 let spec = parse("100..50%").unwrap();
2088 let parts = resolve(&spec, 0, 1000).unwrap();
2089 assert_eq!(parts[0].start_ord, 100);
2090 assert_eq!(parts[0].end_ord, 500);
2091 }
2092
2093 #[test]
2094 fn resolve_form2_three_partition_pct_list() {
2095 let spec = parse("2%,10%,*%").unwrap();
2096 let parts = resolve(&spec, 0, 1000).unwrap();
2097 assert_eq!(parts.len(), 3);
2098 assert_eq!(parts[0].start_ord, 0);
2099 assert_eq!(parts[0].end_ord, 20);
2100 assert_eq!(parts[1].start_ord, 20);
2101 assert_eq!(parts[1].end_ord, 120);
2102 assert_eq!(parts[2].start_ord, 120);
2103 assert_eq!(parts[2].end_ord, 1000);
2104 assert_eq!(parts[2].cardinality(), 880);
2105 }
2106
2107 #[test]
2108 fn resolve_form2_literal_deltas() {
2109 let spec = parse("1000,5000,*").unwrap();
2110 let parts = resolve(&spec, 0, 10000).unwrap();
2111 assert_eq!(parts.len(), 3);
2112 assert_eq!(parts[0].start_ord, 0);
2113 assert_eq!(parts[0].end_ord, 1000);
2114 assert_eq!(parts[1].start_ord, 1000);
2115 assert_eq!(parts[1].end_ord, 6000);
2116 assert_eq!(parts[2].start_ord, 6000);
2117 assert_eq!(parts[2].end_ord, 10000);
2118 }
2119
2120 #[test]
2121 fn resolve_form2_mixed_literal_and_pct_with_star() {
2122 let spec = parse("1000,10%,*").unwrap();
2123 let parts = resolve(&spec, 0, 10000).unwrap();
2124 assert_eq!(parts.len(), 3);
2125 assert_eq!(parts[0].cardinality(), 1000);
2126 assert_eq!(parts[1].cardinality(), 1000); assert_eq!(parts[2].cardinality(), 8000); }
2129
2130 #[test]
2131 fn resolve_form2_short_list_drops_trailing_gap() {
2132 let spec = parse("20%,30%").unwrap();
2133 let parts = resolve(&spec, 0, 1000).unwrap();
2134 assert_eq!(parts.len(), 2);
2135 assert_eq!(parts[0].end_ord, 200);
2136 assert_eq!(parts[1].end_ord, 500); }
2138
2139 #[test]
2140 fn resolve_rejects_over_extent_sum() {
2141 let spec = parse("60%,60%").unwrap();
2142 let err = resolve(&spec, 0, 1000).unwrap_err();
2143 assert!(err.contains("exceeding"), "diagnostic: {err}");
2144 }
2145
2146 #[test]
2147 fn resolve_recipe_against_extent() {
2148 let spec = parse("linear:4").unwrap();
2149 let parts = resolve(&spec, 0, 1000).unwrap();
2150 assert_eq!(parts.len(), 4);
2151 for p in &parts {
2152 assert_eq!(p.cardinality(), 250);
2153 }
2154 }
2155
2156 #[test]
2157 fn resolve_partition_indices_assigned() {
2158 let spec = parse("linear:5").unwrap();
2159 let parts = resolve(&spec, 0, 1000).unwrap();
2160 for (i, p) in parts.iter().enumerate() {
2161 assert_eq!(p.idx, i as u64);
2162 }
2163 }
2164
2165 #[test]
2166 fn resolve_partition_pcts_populated() {
2167 let spec = parse("linear:4").unwrap();
2168 let parts = resolve(&spec, 0, 1000).unwrap();
2169 assert!((parts[0].start_pct - 0.0).abs() < 1e-9);
2170 assert!((parts[0].end_pct - 25.0).abs() < 1e-9);
2171 assert!((parts[3].end_pct - 100.0).abs() < 1e-9);
2172 }
2173
2174 #[test]
2180 fn resolve_fill_and_star_split_coincide_at_90_10() {
2181 let explicit = resolve(
2182 &parse("90%,1%,1%,1%,1%,1%,1%,1%,1%,1%,1%").unwrap(),
2183 0,
2184 1000,
2185 )
2186 .unwrap();
2187 let filled = resolve(&parse("90%,1%,...").unwrap(), 0, 1000).unwrap();
2188 let split = resolve(&parse("90%,*/10").unwrap(), 0, 1000).unwrap();
2189 assert_eq!(explicit.len(), 11);
2190 assert_eq!(filled, explicit);
2191 assert_eq!(split, explicit);
2192 assert_eq!(filled[0].cardinality(), 900);
2193 for p in &filled[1..] {
2194 assert_eq!(p.cardinality(), 10);
2195 }
2196 assert_eq!(filled[10].end_ord, 1000);
2197 }
2198
2199 #[test]
2200 fn resolve_fill_truncates_final_chunk() {
2201 let parts = resolve(&parse("3,2,...").unwrap(), 0, 10).unwrap();
2203 let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2204 assert_eq!(bounds, vec![(0, 3), (3, 5), (5, 7), (7, 9), (9, 10)]);
2205 }
2206
2207 #[test]
2208 fn resolve_fill_with_nothing_left_adds_no_chunks() {
2209 let parts = resolve(&parse("90%,10%,...").unwrap(), 0, 1000).unwrap();
2211 assert_eq!(parts.len(), 2);
2212 assert_eq!(parts[1].end_ord, 1000);
2213 }
2214
2215 #[test]
2216 fn resolve_fill_subordinal_chunk_rejected() {
2217 let err = resolve(&parse("50%,0.01%,...").unwrap(), 0, 100).unwrap_err();
2220 assert!(err.contains("less than one ordinal"), "diagnostic: {err}");
2221 }
2222
2223 #[test]
2224 fn resolve_pct_boundaries_round_at_cumulative_position() {
2225 let parts = resolve(&parse("linear:3").unwrap(), 0, 1000).unwrap();
2230 let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2231 assert_eq!(bounds, vec![(0, 333), (333, 667), (667, 1000)]);
2232 }
2233
2234 #[test]
2235 fn resolve_star_split_distributes_rounding_slack() {
2236 let parts = resolve(&parse("*/3").unwrap(), 0, 100).unwrap();
2239 assert_eq!(parts.len(), 3);
2240 assert_eq!(parts[0].start_ord, 0);
2241 assert_eq!(parts[2].end_ord, 100);
2242 for w in parts.windows(2) {
2243 assert_eq!(w[0].end_ord, w[1].start_ord, "contiguous");
2244 }
2245 let sizes: Vec<u64> = parts.iter().map(|p| p.cardinality()).collect();
2246 assert!(
2247 sizes.iter().all(|s| *s == 33 || *s == 34),
2248 "sizes: {sizes:?}"
2249 );
2250 assert_eq!(sizes.iter().sum::<u64>(), 100);
2251 }
2252
2253 #[test]
2254 fn resolve_star_split_alone_equals_linear_recipe() {
2255 let split = resolve(&parse("*/16").unwrap(), 0, 1600).unwrap();
2256 let linear = resolve(&parse("linear:16").unwrap(), 0, 1600).unwrap();
2257 assert_eq!(split, linear);
2258 }
2259
2260 #[test]
2261 fn resolve_star_split_no_remainder_rejected() {
2262 let err = resolve(&parse("100%,*/4").unwrap(), 0, 1000).unwrap_err();
2263 assert!(err.contains("no remainder"), "diagnostic: {err}");
2264 }
2265
2266 #[test]
2267 fn resolve_star_split_finer_than_remainder_rejected() {
2268 let err = resolve(&parse("90%,*/200").unwrap(), 0, 1000).unwrap_err();
2269 assert!(err.contains("non-empty"), "diagnostic: {err}");
2270 }
2271
2272 #[test]
2273 fn resolve_tail_indices_continue_from_head() {
2274 let parts = resolve(&parse("50%,*/5").unwrap(), 0, 1000).unwrap();
2275 assert_eq!(parts.len(), 6);
2276 for (i, p) in parts.iter().enumerate() {
2277 assert_eq!(p.idx, i as u64);
2278 }
2279 }
2280
2281 #[test]
2282 fn split_evenly_boundaries_monotone_and_exact() {
2283 for (start, end, n) in [
2284 (0u64, 100u64, 7u64),
2285 (5, 5, 1),
2286 (0, 3, 3),
2287 (1000, 10007, 13),
2288 ] {
2289 let chunks = split_evenly(start, end, n);
2290 assert_eq!(chunks.len(), n as usize);
2291 assert_eq!(chunks[0].0, start);
2292 assert_eq!(chunks[n as usize - 1].1, end);
2293 for w in chunks.windows(2) {
2294 assert_eq!(w[0].1, w[1].0);
2295 }
2296 let total: u64 = chunks.iter().map(|(s, e)| e - s).sum();
2297 assert_eq!(total, end - start);
2298 }
2299 }
2300
2301 #[test]
2304 fn parse_tolerates_whitespace_in_lists() {
2305 let spec = parse(" 2% , 10% , *% ").unwrap();
2306 assert_eq!(
2307 spec,
2308 PartitionSpec::delta_list(vec![Bound::Pct(2.0), Bound::Pct(10.0), Bound::Star])
2309 );
2310 }
2311
2312 #[test]
2315 fn partition_roundtrips_through_value_ext() {
2316 let p = Partition {
2317 idx: 2,
2318 count: 4,
2319 start_ord: 100,
2320 end_ord: 500,
2321 start_pct: 10.0,
2322 end_pct: 50.0,
2323 base_extent: 1000,
2324 };
2325 let v = Value::from_partition(p);
2326 let recovered = v.as_partition().expect("downcast");
2327 assert_eq!(recovered.idx, 2);
2328 assert_eq!(recovered.start_ord, 100);
2329 assert_eq!(recovered.end_ord, 500);
2330 assert_eq!(recovered.cardinality(), 400);
2331 }
2332
2333 #[test]
2334 fn partition_spec_roundtrips_through_value_ext() {
2335 let spec = parse("fib:5").unwrap();
2336 let v = Value::from_partition_spec(spec);
2337 let recovered = v.as_partition_spec().expect("downcast");
2338 match &recovered.chunking {
2340 Chunking::DeltaList { deltas } => assert_eq!(deltas.len(), 5),
2341 other => panic!("expected DeltaList, got {other:?}"),
2342 }
2343 }
2344
2345 #[test]
2346 fn partition_list_roundtrips_through_value_ext() {
2347 let spec = parse("linear:4").unwrap();
2348 let parts = resolve(&spec, 0, 1000).unwrap();
2349 let v = Value::from_partition_list(parts);
2350 let recovered = v.as_partition_list().expect("downcast");
2351 assert_eq!(recovered.len(), 4);
2352 assert_eq!(recovered.as_slice()[0].start_ord, 0);
2353 assert_eq!(recovered.as_slice()[3].end_ord, 1000);
2354 }
2355
2356 #[test]
2357 fn non_partition_value_downcast_returns_none() {
2358 let v = Value::U64(42);
2359 assert!(v.as_partition().is_none());
2360 assert!(v.as_partition_spec().is_none());
2361 assert!(v.as_partition_list().is_none());
2362 }
2363
2364 #[test]
2365 fn parse_tolerates_whitespace_in_range() {
2366 let spec = parse(" 0 .. 53 % ").unwrap();
2367 assert_eq!(
2368 spec,
2369 PartitionSpec::single_range(Bound::Ord(0), Bound::Pct(53.0))
2370 );
2371 }
2372
2373 #[test]
2376 fn parse_window_clause() {
2377 let spec = parse("linear:4 in 25%..75%").unwrap();
2378 assert_eq!(spec.window, Some((Bound::Pct(25.0), Bound::Pct(75.0))));
2379 assert_eq!(spec.order, PartitionOrder::Unchanged);
2380 match &spec.chunking {
2381 Chunking::DeltaList { deltas } => assert_eq!(deltas.len(), 4),
2382 other => panic!("expected DeltaList, got {other:?}"),
2383 }
2384 }
2385
2386 #[test]
2387 fn parse_window_requires_range() {
2388 let err = parse("linear:4 in 50%").unwrap_err();
2389 assert!(err.contains("start..end"), "diagnostic: {err}");
2390 }
2391
2392 #[test]
2393 fn parse_window_requires_sized_bounds() {
2394 let err = parse("linear:4 in 0..*").unwrap_err();
2395 assert!(err.contains("sized"), "diagnostic: {err}");
2396 }
2397
2398 #[test]
2399 fn parse_window_clause_position_errors() {
2400 assert!(parse("in 0..50%").unwrap_err().contains("chunking spec"));
2401 assert!(parse("linear:4 in").unwrap_err().contains("window range"));
2402 assert!(
2403 parse("linear:2 in 0..50% in 0..10%")
2404 .unwrap_err()
2405 .contains("at most one")
2406 );
2407 }
2408
2409 #[test]
2410 fn resolve_windowed_chunking_is_window_relative() {
2411 let parts = resolve(&parse("linear:4 in 20%..100%").unwrap(), 0, 1000).unwrap();
2415 let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2416 assert_eq!(
2417 bounds,
2418 vec![(200, 400), (400, 600), (600, 800), (800, 1000)]
2419 );
2420 }
2421
2422 #[test]
2423 fn resolve_windowed_form1_composes() {
2424 let parts = resolve(&parse("0..50% in 50%..100%").unwrap(), 0, 1000).unwrap();
2426 assert_eq!(parts.len(), 1);
2427 assert_eq!((parts[0].start_ord, parts[0].end_ord), (500, 750));
2428 }
2429
2430 #[test]
2431 fn resolve_windowed_tail_tokens() {
2432 let parts = resolve(&parse("90%,*/10 in 0..50%").unwrap(), 0, 1000).unwrap();
2436 assert_eq!(parts.len(), 11);
2437 assert_eq!((parts[0].start_ord, parts[0].end_ord), (0, 450));
2438 assert_eq!(parts[10].end_ord, 500);
2439 assert_eq!(parts[1].cardinality(), 5);
2440 }
2441
2442 #[test]
2445 fn parse_finite_repetition_expands() {
2446 let spec = parse("1%x3").unwrap();
2447 assert_eq!(spec, PartitionSpec::delta_list(vec![Bound::Pct(1.0); 3]));
2448 }
2449
2450 #[test]
2451 fn parse_repetition_zero_rejected() {
2452 let err = parse("1%x0").unwrap_err();
2453 assert!(err.contains(">= 1"), "diagnostic: {err}");
2454 }
2455
2456 #[test]
2457 fn parse_repetition_on_tail_rejected() {
2458 assert!(parse("*x3").is_err());
2459 assert!(parse("...x3").is_err());
2460 }
2461
2462 #[test]
2463 fn resolve_repetition_equals_fill_and_split_at_90_10() {
2464 let explicit = resolve(&parse("90%,1%,...").unwrap(), 0, 1000).unwrap();
2467 let repeated = resolve(&parse("90%,1%x10").unwrap(), 0, 1000).unwrap();
2468 assert_eq!(repeated, explicit);
2469 }
2470
2471 #[test]
2474 fn parse_gap_entry() {
2475 let spec = parse("10%,~80%,10%").unwrap();
2476 assert_eq!(
2477 spec,
2478 PartitionSpec::delta_list(vec![
2479 Bound::Pct(10.0),
2480 Bound::Gap(Box::new(Bound::Pct(80.0))),
2481 Bound::Pct(10.0),
2482 ])
2483 );
2484 }
2485
2486 #[test]
2487 fn parse_gap_requires_sized_bound() {
2488 let err = parse("10%,~*").unwrap_err();
2489 assert!(err.contains("sized"), "diagnostic: {err}");
2490 }
2491
2492 #[test]
2493 fn parse_gap_repetition_rejected() {
2494 let err = parse("10%,~10%x3").unwrap_err();
2495 assert!(err.contains("size the gap"), "diagnostic: {err}");
2496 }
2497
2498 #[test]
2499 fn parse_all_gaps_rejected() {
2500 let err = parse("~10%,~20%").unwrap_err();
2501 assert!(err.contains("emits no partitions"), "diagnostic: {err}");
2502 }
2503
2504 #[test]
2505 fn parse_fill_after_gap_rejected() {
2506 let err = parse("5%,~5%,...").unwrap_err();
2507 assert!(err.contains("emit nothing"), "diagnostic: {err}");
2508 }
2509
2510 #[test]
2511 fn resolve_gap_consumes_without_emitting() {
2512 let parts = resolve(&parse("10%,~80%,10%").unwrap(), 0, 1000).unwrap();
2513 let bounds: Vec<(u64, u64, u64)> = parts
2514 .iter()
2515 .map(|p| (p.idx, p.start_ord, p.end_ord))
2516 .collect();
2517 assert_eq!(bounds, vec![(0, 0, 100), (1, 900, 1000)]);
2519 }
2520
2521 #[test]
2522 fn resolve_gap_counts_toward_star_remainder() {
2523 let parts = resolve(&parse("10%,~40%,*").unwrap(), 0, 1000).unwrap();
2525 let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2526 assert_eq!(bounds, vec![(0, 100), (500, 1000)]);
2527 }
2528
2529 #[test]
2532 fn parse_star_shaped_recipe() {
2533 let spec = parse("50%,*/ratios:1,3").unwrap();
2534 match &spec.chunking {
2535 Chunking::DeltaList { deltas } => {
2536 assert_eq!(deltas.len(), 2);
2537 match &deltas[1] {
2538 Bound::StarShaped(w) => {
2539 assert_eq!(w.len(), 2);
2540 assert!((w[0] - 25.0).abs() < 1e-9);
2541 assert!((w[1] - 75.0).abs() < 1e-9);
2542 }
2543 other => panic!("expected StarShaped, got {other:?}"),
2544 }
2545 }
2546 other => panic!("expected DeltaList, got {other:?}"),
2547 }
2548 }
2549
2550 #[test]
2551 fn parse_star_linear_rejected_with_canonical_hint() {
2552 let err = parse("90%,*/linear:4").unwrap_err();
2553 assert!(
2554 err.contains("*/4"),
2555 "diagnostic should point at `*/N`: {err}"
2556 );
2557 }
2558
2559 #[test]
2560 fn resolve_star_shaped_divides_remainder_by_weights() {
2561 let parts = resolve(&parse("50%,*/ratios:1,3").unwrap(), 0, 1000).unwrap();
2563 let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2564 assert_eq!(bounds, vec![(0, 500), (500, 625), (625, 1000)]);
2565 }
2566
2567 #[test]
2568 fn resolve_star_shaped_alone_covers_extent() {
2569 let parts = resolve(&parse("*/fib:3").unwrap(), 0, 600).unwrap();
2570 let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2572 assert_eq!(bounds, vec![(0, 100), (100, 300), (300, 600)]);
2573 }
2574
2575 #[test]
2576 fn resolve_star_shaped_empty_chunk_rejected() {
2577 let err = resolve(&parse("90%,*/ratios:1,1000").unwrap(), 0, 100).unwrap_err();
2579 assert!(err.contains("empty partition"), "diagnostic: {err}");
2580 }
2581
2582 #[test]
2585 fn parse_order_suffix() {
2586 assert_eq!(
2587 parse("fib:5 largest_first").unwrap().order,
2588 PartitionOrder::LargestFirst
2589 );
2590 assert_eq!(
2591 parse("fib:5 smallest_first").unwrap().order,
2592 PartitionOrder::SmallestFirst
2593 );
2594 assert_eq!(parse("fib:5 random").unwrap().order, PartitionOrder::Random);
2595 assert_eq!(
2596 parse("fib:5 unchanged").unwrap().order,
2597 PartitionOrder::Unchanged
2598 );
2599 assert_eq!(parse("fib:5").unwrap().order, PartitionOrder::Unchanged);
2600 }
2601
2602 #[test]
2603 fn parse_unknown_order_rejected() {
2604 let err = parse("fib:5 descend").unwrap_err();
2605 assert!(err.contains("unknown order"), "diagnostic: {err}");
2606 assert!(
2607 err.contains("largest_first"),
2608 "diagnostic should list options: {err}"
2609 );
2610 }
2611
2612 #[test]
2613 fn parse_bare_direction_words_rejected_with_axis_hint() {
2614 let err = parse("fib:5 ascending").unwrap_err();
2618 assert!(err.contains("smallest_first"), "diagnostic: {err}");
2619 assert!(
2620 err.contains("SIZE"),
2621 "diagnostic should name the axis: {err}"
2622 );
2623 let err = parse("fib:5 descending").unwrap_err();
2624 assert!(err.contains("largest_first"), "diagnostic: {err}");
2625 }
2626
2627 #[test]
2628 fn resolve_largest_first_sorts_by_cardinality_keeping_idx() {
2629 let parts = resolve(&parse("fib:5 largest_first").unwrap(), 0, 1000).unwrap();
2630 for w in parts.windows(2) {
2631 assert!(w[0].cardinality() >= w[1].cardinality(), "largest first");
2632 }
2633 assert_eq!(parts[0].idx, 4);
2636 assert_eq!(parts[4].idx, 0);
2637 }
2638
2639 #[test]
2640 fn resolve_smallest_first_is_stable_for_equal_sizes() {
2641 let parts = resolve(&parse("linear:3 smallest_first").unwrap(), 0, 999).unwrap();
2643 let idxs: Vec<u64> = parts.iter().map(|p| p.idx).collect();
2644 assert_eq!(idxs, vec![0, 1, 2]);
2645 }
2646
2647 #[test]
2648 fn resolve_random_is_deterministic_permutation() {
2649 let a = resolve(&parse("linear:8 random").unwrap(), 0, 800).unwrap();
2650 let b = resolve(&parse("linear:8 random").unwrap(), 0, 800).unwrap();
2651 assert_eq!(a, b, "same spec must shuffle identically");
2652 let mut by_idx = a.clone();
2653 by_idx.sort_by_key(|p| p.idx);
2654 let unchanged = resolve(&parse("linear:8").unwrap(), 0, 800).unwrap();
2655 assert_eq!(
2656 by_idx, unchanged,
2657 "shuffle is a permutation of the same partitions"
2658 );
2659 assert_ne!(
2660 a, unchanged,
2661 "8 elements should not shuffle to identity here"
2662 );
2663 }
2664
2665 #[test]
2666 fn display_round_trips_window_and_order() {
2667 let spec = parse("linear:2 in 0..50% largest_first").unwrap();
2668 let shown = ReflectedValue::display(&spec);
2669 assert!(shown.contains("in 0..50%"), "display: {shown}");
2670 assert!(shown.contains("largest_first"), "display: {shown}");
2671 }
2672
2673 #[test]
2676 fn windowed_partitions_label_against_full_base_frame() {
2677 let parts = resolve(&parse("linear:4 in 20%..100%").unwrap(), 0, 1000).unwrap();
2684 let p = &parts[0];
2685 assert_eq!((p.start_ord, p.end_ord), (200, 400));
2686 assert!(
2687 (p.start_pct - 20.0).abs() < 1e-9,
2688 "start_pct: {}",
2689 p.start_pct
2690 );
2691 assert!((p.end_pct - 40.0).abs() < 1e-9, "end_pct: {}", p.end_pct);
2692 assert_eq!(
2693 p.base_extent, 1000,
2694 "base_extent is the full base, not the window"
2695 );
2696 }
2697
2698 #[test]
2699 fn form1_zero_width_slice_rejected() {
2700 let err = resolve(&parse("0..1%").unwrap(), 0, 10).unwrap_err();
2704 assert!(err.contains("zero ordinals"), "diagnostic: {err}");
2705 }
2706
2707 #[test]
2708 fn delta_list_subordinal_recipe_tails_tolerated() {
2709 let parts = resolve(&parse("mul:0.5").unwrap(), 0, 100).unwrap();
2714 assert_eq!(
2715 parts.len(),
2716 11,
2717 "term count is weight-driven, not extent-driven"
2718 );
2719 assert_eq!(parts.last().unwrap().end_ord, 100);
2720 }
2721}
2722
2723pub fn resolve_over(
2741 value: &Value,
2742 extent: u64,
2743 open_extent: bool,
2744) -> Result<Vec<Partition>, String> {
2745 let reproject = |p: &Partition| -> Partition {
2746 if open_extent || p.base_extent == extent || extent == 0 {
2747 return *p;
2748 }
2749 Partition {
2750 idx: p.idx,
2751 count: p.count,
2752 start_ord: ((p.start_pct / 100.0) * extent as f64).round() as u64,
2753 end_ord: ((p.end_pct / 100.0) * extent as f64).round() as u64,
2754 start_pct: p.start_pct,
2755 end_pct: p.end_pct,
2756 base_extent: extent,
2757 }
2758 };
2759 let reject_open = || {
2760 "an open-extent cursor has no extent to resolve a partition spec against; \
2761 resolve the spec against an explicit extent first and declare the cursor `over p`"
2762 .to_string()
2763 };
2764 match value {
2765 Value::None => Ok(Vec::new()),
2766 Value::Str(s) => {
2767 if open_extent {
2768 return Err(reject_open());
2769 }
2770 resolve(&parse(s.as_ref())?, 0, extent)
2771 }
2772 Value::Ext(b) => {
2773 if let Some(p) = value.as_partition() {
2774 Ok(vec![reproject(p)])
2775 } else if let Some(spec) = value.as_partition_spec() {
2776 if open_extent {
2777 return Err(reject_open());
2778 }
2779 resolve(spec, 0, extent)
2780 } else if let Some(list) = value.as_partition_list() {
2781 Ok(list.as_slice().iter().map(reproject).collect())
2782 } else {
2783 Err(format!(
2784 "`over` expression produced an Ext value of type `{}`; expected Partition, PartitionSpec, or PartitionList",
2785 b.type_name()
2786 ))
2787 }
2788 }
2789 other => Err(format!(
2790 "`over` expression produced an unsupported value; expected a spec string or a partition-typed value, got {other:?}"
2791 )),
2792 }
2793}
2794
2795pub fn cursor_extent(
2799 program: &crate::kernel::PolydatProgram,
2800 state: &mut crate::kernel::PolydatState,
2801 schema: &crate::iteration::source::SourceSchema,
2802) -> u64 {
2803 if let Some((start_out, end_out)) = &schema.extent_outputs {
2804 let start = state.pull(program, start_out).as_u64();
2805 let end = state.pull(program, end_out).as_u64();
2806 let extent = end.saturating_sub(start);
2807 return schema.extent_limit.map(|l| extent.min(l)).unwrap_or(extent);
2808 }
2809 schema.extent.unwrap_or(0)
2810}
2811
2812pub fn cursor_over_partitions(
2819 program: &crate::kernel::PolydatProgram,
2820 state: &mut crate::kernel::PolydatState,
2821 schema: &crate::iteration::source::SourceSchema,
2822) -> Result<Vec<Partition>, String> {
2823 if let Some(parts) = &schema.partitions {
2824 return Ok(parts.clone());
2825 }
2826 let Some(raw) = &schema.partition_output else {
2827 return Ok(Vec::new());
2828 };
2829 let value = state.pull(program, raw).clone();
2830 let extent = cursor_extent(program, state, schema);
2831 let open = !matches!(
2832 schema.cursor_kind,
2833 crate::iteration::source::CursorKind::Range
2834 );
2835 resolve_over(&value, extent, open)
2836}
2837
2838pub fn cursor_extent_on(
2841 kernel: &mut dyn crate::kernel::Kernel,
2842 schema: &crate::iteration::source::SourceSchema,
2843) -> u64 {
2844 if let Some((start_out, end_out)) = &schema.extent_outputs {
2845 let start = kernel.pull(start_out).as_u64();
2846 let end = kernel.pull(end_out).as_u64();
2847 let extent = end.saturating_sub(start);
2848 return schema.extent_limit.map(|l| extent.min(l)).unwrap_or(extent);
2849 }
2850 schema.extent.unwrap_or(0)
2851}
2852
2853pub fn cursor_over_partitions_on(
2856 kernel: &mut dyn crate::kernel::Kernel,
2857 schema: &crate::iteration::source::SourceSchema,
2858) -> Result<Vec<Partition>, String> {
2859 if let Some(parts) = &schema.partitions {
2860 return Ok(parts.clone());
2861 }
2862 let Some(raw) = &schema.partition_output else {
2863 return Ok(Vec::new());
2864 };
2865 let value = kernel.pull(raw);
2866 let extent = cursor_extent_on(kernel, schema);
2867 let open = !matches!(
2868 schema.cursor_kind,
2869 crate::iteration::source::CursorKind::Range
2870 );
2871 resolve_over(&value, extent, open)
2872}
2873
2874pub fn cursor_slot_writes(cursor_name: &str, partition: &Partition) -> [(String, Value); 7] {
2879 let slot = |suffix: &str| format!("{cursor_name}__cursor{suffix}");
2880 [
2881 (slot(""), Value::from_partition(*partition)),
2882 (slot("__idx"), Value::U64(partition.idx)),
2883 (
2884 slot("__partition_count"),
2885 Value::U64(partition.count.max(1)),
2886 ),
2887 (slot("__start_pct"), Value::F64(partition.start_pct)),
2888 (slot("__end_pct"), Value::F64(partition.end_pct)),
2889 (slot("__start_ordinal"), Value::U64(partition.start_ord)),
2890 (slot("__end_ordinal"), Value::U64(partition.end_ord)),
2891 ]
2892}
2893
2894pub fn narrow_cursor(
2900 program: &crate::kernel::PolydatProgram,
2901 state: &mut crate::kernel::PolydatState,
2902 cursor_name: &str,
2903 partition: &Partition,
2904) {
2905 for (slot, v) in cursor_slot_writes(cursor_name, partition) {
2906 if let Some(idx) = program.find_input(&slot) {
2907 state.set_input(idx, v);
2908 }
2909 }
2910}
2911
2912#[cfg(test)]
2913mod over_tests {
2914 use super::*;
2915
2916 #[test]
2917 fn resolve_over_string_spec_against_extent() {
2918 let parts = resolve_over(&Value::Str("20%,30%,*".into()), 1000, false).unwrap();
2919 let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2920 assert_eq!(bounds, vec![(0, 200), (200, 500), (500, 1000)]);
2921 }
2922
2923 #[test]
2924 fn resolve_over_reprojects_partition_onto_cursor_extent() {
2925 let p = resolve(&parse("50%..100%").unwrap(), 0, 100).unwrap()[0];
2926 let got = resolve_over(&Value::from_partition(p), 1000, false).unwrap();
2927 assert_eq!((got[0].start_ord, got[0].end_ord), (500, 1000));
2928 assert_eq!(got[0].base_extent, 1000);
2929 }
2930
2931 #[test]
2932 fn resolve_over_none_is_empty_and_open_rejects_specs() {
2933 assert!(resolve_over(&Value::None, 10, false).unwrap().is_empty());
2934 assert!(resolve_over(&Value::Str("*/2".into()), 10, true).is_err());
2935 }
2936}