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 let mut out = crate::derive_support::try_buffer_for(n, &format!("`{raw}`"))?;
635 out.resize(n as usize, b);
636 return Ok(out);
637 }
638 Ok(vec![parse_bound(raw)?])
639}
640
641fn parse_star_tail(divisor: &str) -> Result<Bound, String> {
644 if !divisor.contains(':') && divisor.contains(',') {
648 let count = divisor.split(',').next().unwrap_or(divisor);
649 return Err(format!(
650 "`*/{count}` consumes the rest of the extent and must be the last \
651 entry in the delta list; got trailing entries after it"
652 ));
653 }
654 if let Some((name, args)) = split_recipe(divisor) {
655 if name == "linear" {
656 return Err(format!(
657 "`*/linear:{args}`: spell an equal-count remainder split as \
658 `*/{args}` — `*/N` is the canonical form"
659 ));
660 }
661 let weights = normalise_weights(&expand_recipe_weights(name, args)?)?;
662 return Ok(Bound::StarShaped(weights));
663 }
664 if divisor.contains('%') || divisor.contains('.') {
665 return Err(format!(
666 "`*/{divisor}`: the divisor after `*/` is a chunk count and must be a \
667 bare integer (e.g. `*/10` = remainder in 10 equal chunks). For \
668 fixed-size chunks repeated until the extent is used up, spell the \
669 size as a delta followed by the fill token: `{divisor},...`"
670 ));
671 }
672 let n: u64 = divisor.parse().map_err(|_| {
673 format!("invalid remainder split `*/{divisor}`: expected `*/N` with integer N >= 1, or `*/recipe:args`")
674 })?;
675 if n == 0 {
676 return Err("`*/0`: the remainder split count must be >= 1".into());
677 }
678 Ok(Bound::StarSplit(n))
679}
680
681fn split_recipe(s: &str) -> Option<(&str, &str)> {
685 let colon = s.find(':')?;
686 let name = &s[..colon];
687 if name.is_empty() {
688 return None;
689 }
690 if !name.chars().all(|c| c.is_ascii_alphabetic() || c == '_') {
691 return None;
692 }
693 Some((name, &s[colon + 1..]))
694}
695
696fn split_range(s: &str) -> Option<(&str, &str)> {
699 s.find("..").map(|idx| (&s[..idx], &s[idx + 2..]))
700}
701
702fn parse_bound(raw: &str) -> Result<Bound, String> {
706 let s = raw.trim();
707 if s.is_empty() {
708 return Err("empty bound".into());
709 }
710 if s == "..." {
712 return Ok(Bound::Fill);
713 }
714 if s == "*" || s == "*%" {
721 return Ok(Bound::Star);
722 }
723 if let Some(num) = s.strip_suffix('%') {
725 let value: f64 = num
726 .trim()
727 .parse()
728 .map_err(|_| format!("invalid percentage `{raw}`: expected a number before `%`"))?;
729 if !(0.0..=100.0).contains(&value) {
730 return Err(format!(
731 "percentage `{raw}` out of range — must be in [0%, 100%]"
732 ));
733 }
734 return Ok(Bound::Pct(value));
735 }
736 if s.contains('.') {
738 let value: f64 = s.parse().map_err(|_| format!("invalid decimal `{raw}`"))?;
739 if !(0.0..=1.0).contains(&value) {
740 return Err(format!(
741 "decimal `{raw}` is ambiguous — fractions must be in [0.0, 1.0]; \
742 did you mean `{}%` (percentage), `0.0{}` (fraction), or `{}` (literal ordinal)?",
743 value,
744 raw.replace('.', ""),
745 raw.replace('.', ""),
746 ));
747 }
748 return Ok(Bound::Frac(value));
749 }
750 let value: u64 = s
752 .parse()
753 .map_err(|_| format!("invalid number `{raw}`: expected an integer ordinal, decimal fraction (0.x), or `N%` percentage"))?;
754 Ok(Bound::Ord(value))
755}
756
757fn expand_recipe_weights(name: &str, args: &str) -> Result<Vec<f64>, String> {
766 let parts: Vec<&str> = args.split(',').map(|s| s.trim()).collect();
767 let weights = match name {
768 "linear" => recipe_linear(&parts)?,
769 "ratios" => recipe_ratios(&parts)?,
770 "mul" => recipe_mul(&parts)?,
771 "bin" => recipe_bin(&parts)?,
772 "fib" => recipe_fib(&parts)?,
773 "ln" => recipe_ln(&parts)?,
774 "geom" => recipe_geom(&parts)?,
775 "zipf" => recipe_zipf(&parts)?,
776 "pareto" => recipe_pareto(&parts)?,
777 "front_heavy" => recipe_front_heavy(&parts)?,
778 "back_heavy" => recipe_back_heavy(&parts)?,
779 _ => {
780 return Err(format!(
781 "unknown recipe `{name}` — supported: linear, ratios, mul, bin, fib, ln, \
782 geom, zipf, pareto, front_heavy, back_heavy"
783 ));
784 }
785 };
786 Ok(weights)
787}
788
789fn parse_u64_arg(arg: &str, ctx: &str) -> Result<u64, String> {
790 arg.parse()
791 .map_err(|_| format!("invalid integer arg `{arg}` for {ctx}"))
792}
793
794fn parse_f64_arg(arg: &str, ctx: &str) -> Result<f64, String> {
795 arg.parse()
796 .map_err(|_| format!("invalid number arg `{arg}` for {ctx}"))
797}
798
799fn weights_for(n: u64, recipe: &str, weight: impl FnMut(u64) -> f64) -> Result<Vec<f64>, String> {
806 let mut out = crate::derive_support::try_buffer_for(n, recipe)?;
807 out.extend((1..=n).map(weight));
808 Ok(out)
809}
810
811fn recipe_linear(args: &[&str]) -> Result<Vec<f64>, String> {
812 if args.len() != 1 {
813 return Err(format!(
814 "linear:N expects exactly 1 argument (the partition count); got {}",
815 args.len()
816 ));
817 }
818 let n = parse_u64_arg(args[0], "linear")?;
819 if n == 0 {
820 return Err("linear:N requires N >= 1".into());
821 }
822 weights_for(n, "linear:N", |_| 1.0)
823}
824
825fn recipe_ratios(args: &[&str]) -> Result<Vec<f64>, String> {
826 if args.is_empty() {
827 return Err("ratios:a,b,c,... requires at least one weight".into());
828 }
829 args.iter().map(|a| parse_f64_arg(a, "ratios")).collect()
830}
831
832fn recipe_mul(args: &[&str]) -> Result<Vec<f64>, String> {
833 let (start, ratio) = match args.len() {
834 1 => (1.0, parse_f64_arg(args[0], "mul")?),
835 2 => (
836 parse_f64_arg(args[0], "mul")?,
837 parse_f64_arg(args[1], "mul")?,
838 ),
839 n => {
840 return Err(format!(
841 "mul:R or mul:S,R expects 1 or 2 arguments; got {n}"
842 ));
843 }
844 };
845 if start <= 0.0 {
846 return Err(format!("mul:S,R requires S > 0; got {start}"));
847 }
848 if ratio <= 0.0 {
849 return Err(format!("mul:R requires R > 0; got {ratio}"));
850 }
851 const HARD_CAP: usize = 64;
860 let mut weights = Vec::with_capacity(HARD_CAP);
861 let mut current = start;
862 for _ in 0..HARD_CAP {
863 if !current.is_finite() || current <= 0.0 {
864 break;
865 }
866 weights.push(current);
867 if ratio < 1.0 && current < start * 0.001 {
868 break;
869 }
870 current *= ratio;
871 if ratio >= 1.0 && current >= start * 1000.0 {
872 if current.is_finite() {
875 weights.push(current);
876 }
877 break;
878 }
879 }
880 if weights.is_empty() {
881 return Err(format!(
882 "mul:{start},{ratio} produced no terms — pick a larger start"
883 ));
884 }
885 Ok(weights)
886}
887
888fn recipe_bin(args: &[&str]) -> Result<Vec<f64>, String> {
889 if args.len() != 1 {
890 return Err(format!(
891 "bin:N expects exactly 1 argument (the term count); got {}",
892 args.len()
893 ));
894 }
895 let n = parse_u64_arg(args[0], "bin")?;
896 if n == 0 {
897 return Err("bin:N requires N >= 1".into());
898 }
899 let degree = n - 1;
901 let mut coeffs = weights_for(n, "bin:N", |_| 1.0)?;
902 for k in 1..=degree {
903 coeffs[k as usize] = coeffs[(k - 1) as usize] * ((degree - k + 1) as f64) / (k as f64);
904 }
905 Ok(coeffs)
906}
907
908fn recipe_fib(args: &[&str]) -> Result<Vec<f64>, String> {
909 if args.len() != 1 {
910 return Err(format!(
911 "fib:N expects exactly 1 argument (the term count); got {}",
912 args.len()
913 ));
914 }
915 let n = parse_u64_arg(args[0], "fib")?;
916 if n == 0 {
917 return Err("fib:N requires N >= 1".into());
918 }
919 let mut weights = crate::derive_support::try_buffer_for(n, "fib:N")?;
922 let (mut a, mut b) = (1u64, 2u64);
923 for _ in 0..n {
924 weights.push(a as f64);
925 let next = a.saturating_add(b);
926 a = b;
927 b = next;
928 }
929 Ok(weights)
930}
931
932fn recipe_ln(args: &[&str]) -> Result<Vec<f64>, String> {
933 if args.len() != 1 {
934 return Err(format!(
935 "ln:N expects exactly 1 argument (the term count); got {}",
936 args.len()
937 ));
938 }
939 let n = parse_u64_arg(args[0], "ln")?;
940 if n == 0 {
941 return Err("ln:N requires N >= 1".into());
942 }
943 weights_for(n, "ln:N", |i| (1.0 + i as f64).ln())
944}
945
946fn recipe_geom(args: &[&str]) -> Result<Vec<f64>, String> {
947 if args.len() != 2 {
948 return Err(format!(
949 "geom:N,R expects exactly 2 arguments; got {}",
950 args.len()
951 ));
952 }
953 let n = parse_u64_arg(args[0], "geom")?;
954 let r = parse_f64_arg(args[1], "geom")?;
955 if n == 0 {
956 return Err("geom:N,R requires N >= 1".into());
957 }
958 if r <= 0.0 {
959 return Err(format!("geom:N,R requires R > 0; got {r}"));
960 }
961 let mut weights = crate::derive_support::try_buffer_for(n, "geom:N,R")?;
962 let mut current = 1.0;
963 for _ in 0..n {
964 weights.push(current);
965 current *= r;
966 }
967 Ok(weights)
968}
969
970fn recipe_zipf(args: &[&str]) -> Result<Vec<f64>, String> {
971 if args.len() != 2 {
972 return Err(format!(
973 "zipf:s,N expects exactly 2 arguments; got {}",
974 args.len()
975 ));
976 }
977 let s = parse_f64_arg(args[0], "zipf")?;
978 let n = parse_u64_arg(args[1], "zipf")?;
979 if s <= 0.0 {
980 return Err(format!("zipf:s,N requires s > 0; got {s}"));
981 }
982 if n == 0 {
983 return Err("zipf:s,N requires N >= 1".into());
984 }
985 weights_for(n, "zipf:s,N", |i| 1.0 / (i as f64).powf(s))
986}
987
988fn recipe_pareto(args: &[&str]) -> Result<Vec<f64>, String> {
989 if args.len() != 2 {
990 return Err(format!(
991 "pareto:alpha,N expects exactly 2 arguments; got {}",
992 args.len()
993 ));
994 }
995 let alpha = parse_f64_arg(args[0], "pareto")?;
996 let n = parse_u64_arg(args[1], "pareto")?;
997 if alpha <= 0.0 {
998 return Err(format!("pareto:alpha,N requires alpha > 0; got {alpha}"));
999 }
1000 if n == 0 {
1001 return Err("pareto:alpha,N requires N >= 1".into());
1002 }
1003 weights_for(n, "pareto:alpha,N", |i| (1.0 / i as f64).powf(alpha))
1004}
1005
1006fn recipe_front_heavy(args: &[&str]) -> Result<Vec<f64>, String> {
1007 if args.len() != 1 {
1008 return Err(format!(
1009 "front_heavy:N expects exactly 1 argument; got {}",
1010 args.len()
1011 ));
1012 }
1013 let n = parse_u64_arg(args[0], "front_heavy")?;
1014 if n == 0 {
1015 return Err("front_heavy:N requires N >= 1".into());
1016 }
1017 weights_for(n, "front_heavy:N", |i| (n + 1 - i) as f64)
1019}
1020
1021fn recipe_back_heavy(args: &[&str]) -> Result<Vec<f64>, String> {
1022 if args.len() != 1 {
1023 return Err(format!(
1024 "back_heavy:N expects exactly 1 argument; got {}",
1025 args.len()
1026 ));
1027 }
1028 let n = parse_u64_arg(args[0], "back_heavy")?;
1029 if n == 0 {
1030 return Err("back_heavy:N requires N >= 1".into());
1031 }
1032 weights_for(n, "back_heavy:N", |i| i as f64)
1033}
1034
1035fn normalise_weights(weights: &[f64]) -> Result<Vec<f64>, String> {
1038 if weights.iter().any(|w| !w.is_finite() || *w < 0.0) {
1039 return Err("recipe produced non-finite or negative weights".into());
1040 }
1041 let sum: f64 = weights.iter().sum();
1042 if sum <= 0.0 {
1043 return Err("recipe produced zero total weight".into());
1044 }
1045 Ok(weights.iter().map(|w| w / sum * 100.0).collect())
1046}
1047
1048fn normalise_to_pct(weights: &[f64]) -> Result<Vec<Bound>, String> {
1051 Ok(normalise_weights(weights)?
1052 .into_iter()
1053 .map(Bound::Pct)
1054 .collect())
1055}
1056
1057pub fn resolve(
1096 spec: &PartitionSpec,
1097 base_start: u64,
1098 base_end: u64,
1099) -> Result<Vec<Partition>, String> {
1100 if base_end < base_start {
1101 return Err(format!(
1102 "resolve: base_end ({base_end}) < base_start ({base_start})"
1103 ));
1104 }
1105 let base_extent = base_end - base_start;
1106 let (dom_start, dom_end) = match &spec.window {
1108 None => (base_start, base_end),
1109 Some((ws, we)) => {
1110 let s = ws
1111 .resolve_against(base_start, base_end)
1112 .expect("window bounds are sized (checked at parse time)");
1113 let e = we
1114 .resolve_against(base_start, base_end)
1115 .expect("window bounds are sized (checked at parse time)");
1116 if e < s {
1117 return Err(format!(
1118 "window `in {ws}..{we}` is empty or reversed against \
1119 base=[{base_start}..{base_end}): start={s}, end={e}"
1120 ));
1121 }
1122 (s, e)
1123 }
1124 };
1125 let dom_extent = dom_end - dom_start;
1126 let frame = Frame {
1129 base_start,
1130 base_extent,
1131 };
1132 let mut partitions = match &spec.chunking {
1133 Chunking::SingleRange { start, end } => {
1134 let start_ord = start
1135 .resolve_against(dom_start, dom_end)
1136 .expect("tail tokens not allowed in SingleRange (checked at parse time)");
1137 let end_ord = end
1138 .resolve_against(dom_start, dom_end)
1139 .expect("tail tokens not allowed in SingleRange (checked at parse time)");
1140 if end_ord < start_ord {
1141 return Err(format!(
1142 "resolved range is empty or reversed: start={start_ord}, end={end_ord} \
1143 (spec start={start}, end={end}, base=[{dom_start}..{dom_end}))"
1144 ));
1145 }
1146 if start_ord == end_ord {
1156 return Err(format!(
1157 "range `{start}..{end}` resolves to zero ordinals \
1158 ([{start_ord}..{end_ord}) against base=[{dom_start}..{dom_end})) — \
1159 the slice rounds to nothing at this extent; widen the range \
1160 or use a larger extent"
1161 ));
1162 }
1163 vec![frame.partition(0, start_ord, end_ord)]
1164 }
1165 Chunking::DeltaList { deltas } => {
1166 resolve_delta_list(deltas, dom_start, dom_end, dom_extent, frame)?
1167 }
1168 };
1169 let count = partitions.len() as u64;
1171 for p in &mut partitions {
1172 p.count = count;
1173 }
1174 apply_order(&mut partitions, spec);
1175 Ok(partitions)
1176}
1177
1178#[derive(Clone, Copy)]
1182struct Frame {
1183 base_start: u64,
1184 base_extent: u64,
1185}
1186
1187impl Frame {
1188 fn partition(&self, idx: u64, start_ord: u64, end_ord: u64) -> Partition {
1189 Partition {
1192 count: 0,
1193 idx,
1194 start_ord,
1195 end_ord,
1196 start_pct: pct_of(start_ord, self.base_start, self.base_extent),
1197 end_pct: pct_of(end_ord, self.base_start, self.base_extent),
1198 base_extent: self.base_extent,
1199 }
1200 }
1201}
1202
1203fn apply_order(partitions: &mut [Partition], spec: &PartitionSpec) {
1211 match spec.order {
1212 PartitionOrder::Unchanged => {}
1213 PartitionOrder::SmallestFirst => {
1214 partitions.sort_by_key(|p| p.cardinality());
1215 }
1216 PartitionOrder::LargestFirst => {
1217 partitions.sort_by_key(|p| std::cmp::Reverse(p.cardinality()));
1218 }
1219 PartitionOrder::Random => {
1220 let mut state = xxhash_rust::xxh3::xxh3_64(format!("{spec:?}").as_bytes());
1221 for i in (1..partitions.len()).rev() {
1222 let j = (splitmix64(&mut state) % (i as u64 + 1)) as usize;
1223 partitions.swap(i, j);
1224 }
1225 }
1226 }
1227}
1228
1229fn splitmix64(state: &mut u64) -> u64 {
1232 *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
1233 let mut z = *state;
1234 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1235 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1236 z ^ (z >> 31)
1237}
1238
1239fn resolve_delta_list(
1240 deltas: &[Bound],
1241 dom_start: u64,
1242 dom_end: u64,
1243 extent: u64,
1244 frame: Frame,
1245) -> Result<Vec<Partition>, String> {
1246 let non_tail_exact: f64 = deltas
1257 .iter()
1258 .filter(|b| !b.is_tail())
1259 .map(|b| delta_exact_ordinals(b, extent))
1260 .sum();
1261 let tolerance = 1e-6 * (extent as f64).max(1.0);
1264 if non_tail_exact > extent as f64 + tolerance {
1265 return Err(format!(
1266 "delta list sums to {} ordinals, exceeding the cursor's extent {extent}; \
1267 trim the list or use a `*` remainder to absorb the overflow",
1268 non_tail_exact.round() as u64
1269 ));
1270 }
1271 let mut partitions: Vec<Partition> = Vec::with_capacity(deltas.len());
1272 let mut cursor = dom_start;
1273 let mut exact_pos = 0.0f64;
1275 let push = |partitions: &mut Vec<Partition>, start: u64, end: u64| {
1276 let idx = partitions.len() as u64;
1277 partitions.push(frame.partition(idx, start, end));
1278 };
1279 let boundary = |exact_pos: f64| -> u64 { (dom_start + exact_pos.round() as u64).min(dom_end) };
1280 for (i, delta) in deltas.iter().enumerate() {
1281 match delta {
1282 Bound::Star => {
1283 exact_pos += extent as f64 - non_tail_exact;
1285 let next = boundary(exact_pos);
1286 push(&mut partitions, cursor, next);
1287 cursor = next;
1288 }
1289 Bound::Fill => {
1290 let chunk = delta_exact_ordinals(&deltas[i - 1], extent);
1296 if chunk < 1.0 {
1297 return Err(format!(
1298 "fill token `...` would repeat a delta of less than one \
1299 ordinal (`{}` resolves to {chunk:.3} ordinals against \
1300 extent {extent})",
1301 deltas[i - 1]
1302 ));
1303 }
1304 while cursor < dom_end {
1305 exact_pos += chunk;
1306 let next = boundary(exact_pos);
1307 push(&mut partitions, cursor, next);
1308 cursor = next;
1309 }
1310 }
1311 Bound::StarSplit(n) => {
1312 let remainder = dom_end - cursor;
1316 if remainder == 0 {
1317 return Err(format!(
1318 "`*/{n}` has no remainder to divide — the preceding deltas \
1319 already cover the extent {extent}"
1320 ));
1321 }
1322 if *n > remainder {
1323 return Err(format!(
1324 "`*/{n}` cannot divide a remainder of {remainder} ordinals \
1325 into {n} non-empty partitions"
1326 ));
1327 }
1328 for (s, e) in try_split_evenly(cursor, dom_end, *n)? {
1329 push(&mut partitions, s, e);
1330 }
1331 cursor = dom_end;
1332 }
1333 Bound::StarShaped(weights) => {
1334 let remainder = dom_end - cursor;
1339 if remainder == 0 {
1340 return Err(format!(
1341 "`*/<recipe>` has no remainder to divide — the preceding \
1342 deltas already cover the extent {extent}"
1343 ));
1344 }
1345 let start = cursor;
1346 let mut cum = 0.0f64;
1347 for w in weights {
1348 cum += w;
1349 let next =
1350 (start + ((cum / 100.0) * remainder as f64).round() as u64).min(dom_end);
1351 if next == cursor {
1352 return Err(format!(
1353 "`*/<recipe>` produces an empty partition — weight \
1354 {w:.3}% of a {remainder}-ordinal remainder rounds to \
1355 zero ordinals; use fewer/coarser weights or a larger \
1356 remainder"
1357 ));
1358 }
1359 push(&mut partitions, cursor, next);
1360 cursor = next;
1361 }
1362 exact_pos += remainder as f64;
1363 }
1364 Bound::Gap(inner) => {
1365 exact_pos += delta_exact_ordinals(inner, extent);
1369 cursor = boundary(exact_pos);
1370 }
1371 other => {
1372 exact_pos += delta_exact_ordinals(other, extent);
1373 let next = boundary(exact_pos);
1374 push(&mut partitions, cursor, next);
1375 cursor = next;
1376 }
1377 }
1378 }
1379 debug_assert!(cursor <= dom_end);
1383 Ok(partitions)
1384}
1385
1386fn delta_exact_ordinals(b: &Bound, extent: u64) -> f64 {
1392 match b {
1393 Bound::Pct(p) => (p / 100.0) * extent as f64,
1394 Bound::Frac(f) => f * extent as f64,
1395 Bound::Ord(o) => *o as f64,
1396 Bound::Gap(inner) => delta_exact_ordinals(inner, extent),
1397 Bound::Star | Bound::Fill | Bound::StarSplit(_) | Bound::StarShaped(_) => {
1398 unreachable!("tail tokens handled separately")
1399 }
1400 }
1401}
1402
1403pub fn subdivide_partition(p: &Partition, n: u64) -> Result<Vec<Partition>, String> {
1417 let card = p.cardinality();
1418 if n == 0 {
1419 return Err("subdivide(p, 0): the sub-partition count must be >= 1".into());
1420 }
1421 if n > card {
1422 return Err(format!(
1423 "subdivide(p, {n}): cannot divide partition #{} of {card} ordinals \
1424 into {n} non-empty sub-partitions",
1425 p.idx
1426 ));
1427 }
1428 let pct_at = |ord: u64| -> f64 {
1429 p.start_pct + (ord - p.start_ord) as f64 / card as f64 * (p.end_pct - p.start_pct)
1430 };
1431 Ok(try_split_evenly(p.start_ord, p.end_ord, n)?
1432 .into_iter()
1433 .enumerate()
1434 .map(|(i, (start_ord, end_ord))| Partition {
1435 idx: i as u64,
1436 count: n,
1437 start_ord,
1438 end_ord,
1439 start_pct: pct_at(start_ord),
1440 end_pct: pct_at(end_ord),
1441 base_extent: p.base_extent,
1442 })
1443 .collect())
1444}
1445
1446pub fn split_evenly(start_ord: u64, end_ord: u64, n: u64) -> Vec<(u64, u64)> {
1456 try_split_evenly(start_ord, end_ord, n).unwrap_or_else(|e| panic!("{e}"))
1457}
1458
1459pub fn try_split_evenly(start_ord: u64, end_ord: u64, n: u64) -> Result<Vec<(u64, u64)>, String> {
1465 debug_assert!(n >= 1, "split_evenly requires n >= 1");
1466 debug_assert!(end_ord >= start_ord);
1467 let span = (end_ord - start_ord) as u128;
1468 let n_wide = n as u128;
1469 let boundary =
1470 |i: u64| -> u64 { start_ord + ((i as u128 * span + n_wide / 2) / n_wide) as u64 };
1471 let mut out = crate::derive_support::try_buffer_for(n, "split into n partitions")?;
1472 out.extend((0..n).map(|i| (boundary(i), boundary(i + 1))));
1473 Ok(out)
1474}
1475
1476#[inline]
1477fn pct_of(ordinal: u64, base_start: u64, extent: u64) -> f64 {
1478 if extent == 0 {
1479 0.0
1480 } else {
1481 (ordinal - base_start) as f64 * 100.0 / extent as f64
1482 }
1483}
1484
1485impl ReflectedValue for Partition {
1497 fn type_name(&self) -> &str {
1498 "Partition"
1499 }
1500
1501 fn display(&self) -> String {
1502 format!(
1503 "Partition({}/{} [{}..{}) [{:.2}%..{:.2}%))",
1504 self.idx, self.count, self.start_ord, self.end_ord, self.start_pct, self.end_pct,
1505 )
1506 }
1507
1508 fn to_json_value(&self) -> serde_json::Value {
1509 serde_json::json!({
1510 "idx": self.idx,
1511 "count": self.count,
1512 "start_ord": self.start_ord,
1513 "end_ord": self.end_ord,
1514 "start_pct": self.start_pct,
1515 "end_pct": self.end_pct,
1516 "base_extent": self.base_extent,
1517 "cardinality": self.cardinality(),
1518 })
1519 }
1520
1521 fn as_any(&self) -> &dyn std::any::Any {
1522 self
1523 }
1524
1525 fn clone_reflected(&self) -> Box<dyn ReflectedValue> {
1526 Box::new(*self)
1527 }
1528}
1529
1530impl ReflectedValue for PartitionSpec {
1531 fn type_name(&self) -> &str {
1532 "PartitionSpec"
1533 }
1534
1535 fn display(&self) -> String {
1536 let chunking = match &self.chunking {
1537 Chunking::SingleRange { start, end } => format!("{start}..{end}"),
1538 Chunking::DeltaList { deltas } => {
1539 let parts: Vec<String> = deltas.iter().map(|b| b.to_string()).collect();
1540 parts.join(",")
1541 }
1542 };
1543 let window = match &self.window {
1544 Some((s, e)) => format!(" in {s}..{e}"),
1545 None => String::new(),
1546 };
1547 let order = match self.order {
1548 PartitionOrder::Unchanged => String::new(),
1549 o => format!(" {o}"),
1550 };
1551 format!("PartitionSpec({chunking}{window}{order})")
1552 }
1553
1554 fn to_json_value(&self) -> serde_json::Value {
1555 serde_json::Value::String(self.display())
1556 }
1557
1558 fn as_any(&self) -> &dyn std::any::Any {
1559 self
1560 }
1561
1562 fn clone_reflected(&self) -> Box<dyn ReflectedValue> {
1563 Box::new(self.clone())
1564 }
1565}
1566
1567#[derive(Debug, Clone)]
1573pub struct PartitionList(pub Arc<Vec<Partition>>);
1574
1575impl PartitionList {
1576 pub fn new(partitions: Vec<Partition>) -> Self {
1578 Self(Arc::new(partitions))
1579 }
1580
1581 pub fn len(&self) -> usize {
1583 self.0.len()
1584 }
1585
1586 pub fn is_empty(&self) -> bool {
1588 self.0.is_empty()
1589 }
1590
1591 pub fn as_slice(&self) -> &[Partition] {
1593 &self.0
1594 }
1595}
1596
1597impl ReflectedValue for PartitionList {
1598 fn type_name(&self) -> &str {
1599 "PartitionList"
1600 }
1601
1602 fn display(&self) -> String {
1603 let parts: Vec<String> = self
1604 .0
1605 .iter()
1606 .map(|p| format!("[{}..{})", p.start_ord, p.end_ord))
1607 .collect();
1608 format!("PartitionList[{}]={}", self.0.len(), parts.join(","))
1609 }
1610
1611 fn to_json_value(&self) -> serde_json::Value {
1612 serde_json::Value::Array(self.0.iter().map(|p| p.to_json_value()).collect())
1613 }
1614
1615 fn as_any(&self) -> &dyn std::any::Any {
1616 self
1617 }
1618
1619 fn clone_reflected(&self) -> Box<dyn ReflectedValue> {
1620 Box::new(self.clone())
1621 }
1622}
1623
1624impl Value {
1628 pub fn from_partition(p: Partition) -> Self {
1630 Value::Ext(Box::new(p))
1631 }
1632
1633 pub fn from_partition_spec(s: PartitionSpec) -> Self {
1635 Value::Ext(Box::new(s))
1636 }
1637
1638 pub fn from_partition_list(parts: Vec<Partition>) -> Self {
1643 Value::Ext(Box::new(PartitionList::new(parts)))
1644 }
1645
1646 pub fn as_partition(&self) -> Option<&Partition> {
1649 match self {
1650 Value::Ext(b) => b.as_any().downcast_ref::<Partition>(),
1651 _ => None,
1652 }
1653 }
1654
1655 pub fn as_partition_spec(&self) -> Option<&PartitionSpec> {
1658 match self {
1659 Value::Ext(b) => b.as_any().downcast_ref::<PartitionSpec>(),
1660 _ => None,
1661 }
1662 }
1663
1664 pub fn as_partition_list(&self) -> Option<&PartitionList> {
1667 match self {
1668 Value::Ext(b) => b.as_any().downcast_ref::<PartitionList>(),
1669 _ => None,
1670 }
1671 }
1672}
1673
1674#[cfg(test)]
1679mod tests {
1680 use super::*;
1681
1682 #[test]
1685 fn parse_bound_percentage() {
1686 assert_eq!(parse_bound("53%").unwrap(), Bound::Pct(53.0));
1687 assert_eq!(parse_bound("0%").unwrap(), Bound::Pct(0.0));
1688 assert_eq!(parse_bound("100%").unwrap(), Bound::Pct(100.0));
1689 assert_eq!(parse_bound("0.5%").unwrap(), Bound::Pct(0.5));
1690 }
1691
1692 #[test]
1693 fn parse_bound_percentage_out_of_range_rejected() {
1694 assert!(parse_bound("101%").is_err());
1695 assert!(parse_bound("-1%").is_err());
1696 }
1697
1698 #[test]
1699 fn parse_bound_fraction() {
1700 assert_eq!(parse_bound("0.5").unwrap(), Bound::Frac(0.5));
1701 assert_eq!(parse_bound("0.0").unwrap(), Bound::Frac(0.0));
1702 assert_eq!(parse_bound("1.0").unwrap(), Bound::Frac(1.0));
1703 assert_eq!(parse_bound("0.123").unwrap(), Bound::Frac(0.123));
1704 }
1705
1706 #[test]
1707 fn parse_bound_fraction_out_of_range_rejected() {
1708 let err = parse_bound("1.5").unwrap_err();
1709 assert!(
1710 err.contains("ambiguous"),
1711 "diagnostic should explain: {err}"
1712 );
1713 }
1714
1715 #[test]
1716 fn parse_bound_literal_ordinal() {
1717 assert_eq!(parse_bound("0").unwrap(), Bound::Ord(0));
1718 assert_eq!(parse_bound("100").unwrap(), Bound::Ord(100));
1719 assert_eq!(parse_bound("999999").unwrap(), Bound::Ord(999_999));
1720 }
1721
1722 #[test]
1723 fn parse_bound_star_token() {
1724 assert_eq!(parse_bound("*").unwrap(), Bound::Star);
1725 assert_eq!(parse_bound("*%").unwrap(), Bound::Star);
1726 }
1727
1728 #[test]
1731 fn parse_form1_simple_pct() {
1732 let spec = parse("0..53%").unwrap();
1733 assert_eq!(
1734 spec,
1735 PartitionSpec::single_range(Bound::Ord(0), Bound::Pct(53.0))
1736 );
1737 }
1738
1739 #[test]
1740 fn parse_form1_brackets_tolerated() {
1741 let canonical = PartitionSpec::single_range(Bound::Ord(0), Bound::Pct(53.0));
1742 assert_eq!(parse("[0..53%]").unwrap(), canonical);
1743 assert_eq!(parse("[0..53%)").unwrap(), canonical);
1744 assert_eq!(parse("(0..53%]").unwrap(), canonical);
1745 }
1746
1747 #[test]
1748 fn parse_form1_fraction_form() {
1749 let spec = parse("0..0.53").unwrap();
1750 assert_eq!(
1751 spec,
1752 PartitionSpec::single_range(Bound::Ord(0), Bound::Frac(0.53))
1753 );
1754 }
1755
1756 #[test]
1757 fn parse_form1_literal_ordinals() {
1758 let spec = parse("100..1000").unwrap();
1759 assert_eq!(
1760 spec,
1761 PartitionSpec::single_range(Bound::Ord(100), Bound::Ord(1000))
1762 );
1763 }
1764
1765 #[test]
1766 fn parse_form1_mixed_literal_and_pct() {
1767 let spec = parse("100..50%").unwrap();
1768 assert_eq!(
1769 spec,
1770 PartitionSpec::single_range(Bound::Ord(100), Bound::Pct(50.0))
1771 );
1772 }
1773
1774 #[test]
1775 fn parse_form1_mixed_frac_and_literal() {
1776 let spec = parse("0.10..10000").unwrap();
1777 assert_eq!(
1778 spec,
1779 PartitionSpec::single_range(Bound::Frac(0.10), Bound::Ord(10000))
1780 );
1781 }
1782
1783 #[test]
1784 fn parse_form1_rejects_star() {
1785 assert!(parse("0..*").is_err());
1786 assert!(parse("*..50%").is_err());
1787 }
1788
1789 #[test]
1792 fn parse_form2_with_star() {
1793 let spec = parse("2%,10%,*%").unwrap();
1794 assert_eq!(
1795 spec,
1796 PartitionSpec::delta_list(vec![Bound::Pct(2.0), Bound::Pct(10.0), Bound::Star])
1797 );
1798 }
1799
1800 #[test]
1801 fn parse_form2_fraction_equivalent() {
1802 let spec = parse("0.02,0.10,*").unwrap();
1803 assert_eq!(
1804 spec,
1805 PartitionSpec::delta_list(vec![Bound::Frac(0.02), Bound::Frac(0.10), Bound::Star])
1806 );
1807 }
1808
1809 #[test]
1810 fn parse_form2_literal_deltas() {
1811 let spec = parse("1000,5000,*").unwrap();
1812 assert_eq!(
1813 spec,
1814 PartitionSpec::delta_list(vec![Bound::Ord(1000), Bound::Ord(5000), Bound::Star])
1815 );
1816 }
1817
1818 #[test]
1819 fn parse_form2_mixed_entries() {
1820 let spec = parse("1000,10%,*").unwrap();
1821 assert_eq!(
1822 spec,
1823 PartitionSpec::delta_list(vec![Bound::Ord(1000), Bound::Pct(10.0), Bound::Star])
1824 );
1825 }
1826
1827 #[test]
1828 fn parse_form2_short_list_no_star() {
1829 let spec = parse("20%,30%").unwrap();
1830 assert_eq!(
1831 spec,
1832 PartitionSpec::delta_list(vec![Bound::Pct(20.0), Bound::Pct(30.0)])
1833 );
1834 }
1835
1836 #[test]
1837 fn parse_form2_rejects_multiple_stars() {
1838 let err = parse("*,*").unwrap_err();
1839 assert!(err.contains("at most one"), "diagnostic: {err}");
1840 }
1841
1842 #[test]
1845 fn parse_form2_fill_token() {
1846 let spec = parse("90%,1%,...").unwrap();
1847 assert_eq!(
1848 spec,
1849 PartitionSpec::delta_list(vec![Bound::Pct(90.0), Bound::Pct(1.0), Bound::Fill])
1850 );
1851 }
1852
1853 #[test]
1854 fn parse_form2_star_split_token() {
1855 let spec = parse("90%,*/10").unwrap();
1856 assert_eq!(
1857 spec,
1858 PartitionSpec::delta_list(vec![Bound::Pct(90.0), Bound::StarSplit(10)])
1859 );
1860 }
1861
1862 #[test]
1863 fn parse_star_split_alone_is_whole_extent_split() {
1864 let spec = parse("*/16").unwrap();
1866 assert_eq!(spec, PartitionSpec::delta_list(vec![Bound::StarSplit(16)]));
1867 }
1868
1869 #[test]
1870 fn parse_fill_alone_rejected_with_hint() {
1871 let err = parse("...").unwrap_err();
1872 assert!(
1873 err.contains("preceding delta") || err.contains("before it"),
1874 "diagnostic: {err}"
1875 );
1876 }
1877
1878 #[test]
1879 fn parse_fill_first_in_list_rejected() {
1880 let err = parse("...,10%").unwrap_err();
1881 assert!(
1882 err.contains("before it") || err.contains("last entry"),
1883 "diagnostic: {err}"
1884 );
1885 }
1886
1887 #[test]
1888 fn parse_fill_not_last_rejected() {
1889 let err = parse("1%,...,10%").unwrap_err();
1890 assert!(err.contains("last entry"), "diagnostic: {err}");
1891 }
1892
1893 #[test]
1894 fn parse_star_split_not_last_rejected() {
1895 let err = parse("*/4,10%").unwrap_err();
1896 assert!(err.contains("last entry"), "diagnostic: {err}");
1897 }
1898
1899 #[test]
1900 fn parse_rejects_mixed_tail_tokens() {
1901 let err = parse("1%,*,...").unwrap_err();
1902 assert!(err.contains("at most one"), "diagnostic: {err}");
1903 let err = parse("1%,*,*/4").unwrap_err();
1904 assert!(err.contains("at most one"), "diagnostic: {err}");
1905 }
1906
1907 #[test]
1908 fn parse_star_split_pct_divisor_rejected_with_teaching_hint() {
1909 let err = parse("90%,*/1%").unwrap_err();
1913 assert!(err.contains("chunk count"), "diagnostic: {err}");
1914 assert!(
1915 err.contains("1%,..."),
1916 "diagnostic should teach the fill form: {err}"
1917 );
1918 let err = parse("90%,*/0.01").unwrap_err();
1919 assert!(err.contains("chunk count"), "diagnostic: {err}");
1920 }
1921
1922 #[test]
1923 fn parse_star_split_zero_rejected() {
1924 let err = parse("90%,*/0").unwrap_err();
1925 assert!(err.contains(">= 1"), "diagnostic: {err}");
1926 }
1927
1928 #[test]
1929 fn parse_form1_rejects_tail_tokens() {
1930 assert!(parse("0..*/4").is_err());
1931 assert!(parse("0....").is_err());
1934 }
1935
1936 fn deltas_only(spec: PartitionSpec) -> Vec<Bound> {
1939 match spec.chunking {
1940 Chunking::DeltaList { deltas } => deltas,
1941 other => panic!("expected DeltaList, got {other:?}"),
1942 }
1943 }
1944
1945 fn pcts_of(spec: PartitionSpec) -> Vec<f64> {
1946 deltas_only(spec)
1947 .into_iter()
1948 .map(|b| match b {
1949 Bound::Pct(p) => p,
1950 other => panic!("expected Pct, got {other:?}"),
1951 })
1952 .collect()
1953 }
1954
1955 #[test]
1956 fn recipe_linear_uniform_split() {
1957 let pcts = pcts_of(parse("linear:4").unwrap());
1958 assert_eq!(pcts.len(), 4);
1959 for p in &pcts {
1960 assert!((p - 25.0).abs() < 1e-9, "expected 25%, got {p}");
1961 }
1962 }
1963
1964 #[test]
1965 fn recipe_ratios_normalises_weights() {
1966 let pcts = pcts_of(parse("ratios:1,1,2").unwrap());
1967 assert_eq!(pcts.len(), 3);
1968 assert!((pcts[0] - 25.0).abs() < 1e-9);
1969 assert!((pcts[1] - 25.0).abs() < 1e-9);
1970 assert!((pcts[2] - 50.0).abs() < 1e-9);
1971 }
1972
1973 #[test]
1974 fn recipe_bin_5_is_five_terms_of_binomial_expansion() {
1975 let pcts = pcts_of(parse("bin:5").unwrap());
1977 assert_eq!(pcts.len(), 5);
1978 let expected = [1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0];
1979 for (i, e) in expected.iter().enumerate() {
1980 assert!(
1981 (pcts[i] - e * 100.0).abs() < 1e-9,
1982 "term {i}: {} vs {}",
1983 pcts[i],
1984 e * 100.0
1985 );
1986 }
1987 }
1988
1989 #[test]
1990 fn recipe_fib_7_uses_distinct_fibonacci() {
1991 let pcts = pcts_of(parse("fib:7").unwrap());
1993 assert_eq!(pcts.len(), 7);
1994 let expected_weights = [1.0, 2.0, 3.0, 5.0, 8.0, 13.0, 21.0];
1995 let sum: f64 = expected_weights.iter().sum();
1996 for (i, w) in expected_weights.iter().enumerate() {
1997 assert!((pcts[i] - w / sum * 100.0).abs() < 1e-9);
1998 }
1999 }
2000
2001 #[test]
2002 fn recipe_ln_5_log_spaced() {
2003 let pcts = pcts_of(parse("ln:5").unwrap());
2004 assert_eq!(pcts.len(), 5);
2005 for i in 1..pcts.len() {
2007 assert!(pcts[i] > pcts[i - 1], "ln:N should be monotonic");
2008 }
2009 let total: f64 = pcts.iter().sum();
2011 assert!((total - 100.0).abs() < 1e-9, "total: {total}");
2012 }
2013
2014 #[test]
2015 fn recipe_mul_decay_tail_off() {
2016 let pcts = pcts_of(parse("mul:0.5").unwrap());
2019 assert!(!pcts.is_empty());
2020 let total: f64 = pcts.iter().sum();
2021 assert!((total - 100.0).abs() < 1e-9, "total: {total}");
2022 assert!(pcts[0] > pcts[1]);
2024 }
2025
2026 #[test]
2027 fn recipe_mul_growth_caps_at_3_orders_of_magnitude() {
2028 let pcts = pcts_of(parse("mul:2").unwrap());
2031 assert!(!pcts.is_empty());
2032 assert!(pcts.len() < 64, "should terminate well before hard cap");
2033 let total: f64 = pcts.iter().sum();
2034 assert!((total - 100.0).abs() < 1e-9, "total: {total}");
2035 }
2036
2037 #[test]
2038 fn recipe_mul_with_start_and_ratio() {
2039 let pcts = pcts_of(parse("mul:5,0.5").unwrap());
2041 let total: f64 = pcts.iter().sum();
2042 assert!((total - 100.0).abs() < 1e-9, "total: {total}");
2043 }
2044
2045 #[test]
2046 fn recipe_geom_fixed_term_count() {
2047 let pcts = pcts_of(parse("geom:5,2").unwrap());
2048 assert_eq!(pcts.len(), 5);
2049 let expected_total: f64 = 31.0;
2051 let expected = [1.0, 2.0, 4.0, 8.0, 16.0];
2052 for (i, e) in expected.iter().enumerate() {
2053 assert!((pcts[i] - e / expected_total * 100.0).abs() < 1e-9);
2054 }
2055 }
2056
2057 #[test]
2058 fn recipe_front_heavy_declining() {
2059 let pcts = pcts_of(parse("front_heavy:4").unwrap());
2060 assert_eq!(pcts.len(), 4);
2061 for i in 1..pcts.len() {
2062 assert!(
2063 pcts[i] < pcts[i - 1],
2064 "front_heavy should be monotonic-declining"
2065 );
2066 }
2067 }
2068
2069 #[test]
2070 fn recipe_back_heavy_growing() {
2071 let pcts = pcts_of(parse("back_heavy:4").unwrap());
2072 assert_eq!(pcts.len(), 4);
2073 for i in 1..pcts.len() {
2074 assert!(
2075 pcts[i] > pcts[i - 1],
2076 "back_heavy should be monotonic-growing"
2077 );
2078 }
2079 }
2080
2081 #[test]
2082 fn recipe_unknown_name_rejected() {
2083 let err = parse("blorp:3").unwrap_err();
2084 assert!(err.contains("unknown recipe"), "diagnostic: {err}");
2085 assert!(
2086 err.contains("linear"),
2087 "should list supported recipes: {err}"
2088 );
2089 }
2090
2091 #[test]
2094 fn resolve_form1_percentage_against_extent() {
2095 let spec = parse("0..50%").unwrap();
2096 let parts = resolve(&spec, 0, 1000).unwrap();
2097 assert_eq!(parts.len(), 1);
2098 assert_eq!(parts[0].start_ord, 0);
2099 assert_eq!(parts[0].end_ord, 500);
2100 assert_eq!(parts[0].cardinality(), 500);
2101 }
2102
2103 #[test]
2104 fn resolve_form1_literal_ordinals() {
2105 let spec = parse("100..1000").unwrap();
2106 let parts = resolve(&spec, 0, 10000).unwrap();
2107 assert_eq!(parts[0].start_ord, 100);
2108 assert_eq!(parts[0].end_ord, 1000);
2109 assert_eq!(parts[0].cardinality(), 900);
2110 }
2111
2112 #[test]
2113 fn resolve_form1_mixed_literal_and_pct() {
2114 let spec = parse("100..50%").unwrap();
2115 let parts = resolve(&spec, 0, 1000).unwrap();
2116 assert_eq!(parts[0].start_ord, 100);
2117 assert_eq!(parts[0].end_ord, 500);
2118 }
2119
2120 #[test]
2121 fn resolve_form2_three_partition_pct_list() {
2122 let spec = parse("2%,10%,*%").unwrap();
2123 let parts = resolve(&spec, 0, 1000).unwrap();
2124 assert_eq!(parts.len(), 3);
2125 assert_eq!(parts[0].start_ord, 0);
2126 assert_eq!(parts[0].end_ord, 20);
2127 assert_eq!(parts[1].start_ord, 20);
2128 assert_eq!(parts[1].end_ord, 120);
2129 assert_eq!(parts[2].start_ord, 120);
2130 assert_eq!(parts[2].end_ord, 1000);
2131 assert_eq!(parts[2].cardinality(), 880);
2132 }
2133
2134 #[test]
2135 fn resolve_form2_literal_deltas() {
2136 let spec = parse("1000,5000,*").unwrap();
2137 let parts = resolve(&spec, 0, 10000).unwrap();
2138 assert_eq!(parts.len(), 3);
2139 assert_eq!(parts[0].start_ord, 0);
2140 assert_eq!(parts[0].end_ord, 1000);
2141 assert_eq!(parts[1].start_ord, 1000);
2142 assert_eq!(parts[1].end_ord, 6000);
2143 assert_eq!(parts[2].start_ord, 6000);
2144 assert_eq!(parts[2].end_ord, 10000);
2145 }
2146
2147 #[test]
2148 fn resolve_form2_mixed_literal_and_pct_with_star() {
2149 let spec = parse("1000,10%,*").unwrap();
2150 let parts = resolve(&spec, 0, 10000).unwrap();
2151 assert_eq!(parts.len(), 3);
2152 assert_eq!(parts[0].cardinality(), 1000);
2153 assert_eq!(parts[1].cardinality(), 1000); assert_eq!(parts[2].cardinality(), 8000); }
2156
2157 #[test]
2158 fn resolve_form2_short_list_drops_trailing_gap() {
2159 let spec = parse("20%,30%").unwrap();
2160 let parts = resolve(&spec, 0, 1000).unwrap();
2161 assert_eq!(parts.len(), 2);
2162 assert_eq!(parts[0].end_ord, 200);
2163 assert_eq!(parts[1].end_ord, 500); }
2165
2166 #[test]
2167 fn resolve_rejects_over_extent_sum() {
2168 let spec = parse("60%,60%").unwrap();
2169 let err = resolve(&spec, 0, 1000).unwrap_err();
2170 assert!(err.contains("exceeding"), "diagnostic: {err}");
2171 }
2172
2173 #[test]
2174 fn resolve_recipe_against_extent() {
2175 let spec = parse("linear:4").unwrap();
2176 let parts = resolve(&spec, 0, 1000).unwrap();
2177 assert_eq!(parts.len(), 4);
2178 for p in &parts {
2179 assert_eq!(p.cardinality(), 250);
2180 }
2181 }
2182
2183 #[test]
2184 fn resolve_partition_indices_assigned() {
2185 let spec = parse("linear:5").unwrap();
2186 let parts = resolve(&spec, 0, 1000).unwrap();
2187 for (i, p) in parts.iter().enumerate() {
2188 assert_eq!(p.idx, i as u64);
2189 }
2190 }
2191
2192 #[test]
2193 fn resolve_partition_pcts_populated() {
2194 let spec = parse("linear:4").unwrap();
2195 let parts = resolve(&spec, 0, 1000).unwrap();
2196 assert!((parts[0].start_pct - 0.0).abs() < 1e-9);
2197 assert!((parts[0].end_pct - 25.0).abs() < 1e-9);
2198 assert!((parts[3].end_pct - 100.0).abs() < 1e-9);
2199 }
2200
2201 #[test]
2207 fn resolve_fill_and_star_split_coincide_at_90_10() {
2208 let explicit = resolve(
2209 &parse("90%,1%,1%,1%,1%,1%,1%,1%,1%,1%,1%").unwrap(),
2210 0,
2211 1000,
2212 )
2213 .unwrap();
2214 let filled = resolve(&parse("90%,1%,...").unwrap(), 0, 1000).unwrap();
2215 let split = resolve(&parse("90%,*/10").unwrap(), 0, 1000).unwrap();
2216 assert_eq!(explicit.len(), 11);
2217 assert_eq!(filled, explicit);
2218 assert_eq!(split, explicit);
2219 assert_eq!(filled[0].cardinality(), 900);
2220 for p in &filled[1..] {
2221 assert_eq!(p.cardinality(), 10);
2222 }
2223 assert_eq!(filled[10].end_ord, 1000);
2224 }
2225
2226 #[test]
2227 fn resolve_fill_truncates_final_chunk() {
2228 let parts = resolve(&parse("3,2,...").unwrap(), 0, 10).unwrap();
2230 let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2231 assert_eq!(bounds, vec![(0, 3), (3, 5), (5, 7), (7, 9), (9, 10)]);
2232 }
2233
2234 #[test]
2235 fn resolve_fill_with_nothing_left_adds_no_chunks() {
2236 let parts = resolve(&parse("90%,10%,...").unwrap(), 0, 1000).unwrap();
2238 assert_eq!(parts.len(), 2);
2239 assert_eq!(parts[1].end_ord, 1000);
2240 }
2241
2242 #[test]
2243 fn resolve_fill_subordinal_chunk_rejected() {
2244 let err = resolve(&parse("50%,0.01%,...").unwrap(), 0, 100).unwrap_err();
2247 assert!(err.contains("less than one ordinal"), "diagnostic: {err}");
2248 }
2249
2250 #[test]
2251 fn resolve_pct_boundaries_round_at_cumulative_position() {
2252 let parts = resolve(&parse("linear:3").unwrap(), 0, 1000).unwrap();
2257 let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2258 assert_eq!(bounds, vec![(0, 333), (333, 667), (667, 1000)]);
2259 }
2260
2261 #[test]
2262 fn resolve_star_split_distributes_rounding_slack() {
2263 let parts = resolve(&parse("*/3").unwrap(), 0, 100).unwrap();
2266 assert_eq!(parts.len(), 3);
2267 assert_eq!(parts[0].start_ord, 0);
2268 assert_eq!(parts[2].end_ord, 100);
2269 for w in parts.windows(2) {
2270 assert_eq!(w[0].end_ord, w[1].start_ord, "contiguous");
2271 }
2272 let sizes: Vec<u64> = parts.iter().map(|p| p.cardinality()).collect();
2273 assert!(
2274 sizes.iter().all(|s| *s == 33 || *s == 34),
2275 "sizes: {sizes:?}"
2276 );
2277 assert_eq!(sizes.iter().sum::<u64>(), 100);
2278 }
2279
2280 #[test]
2281 fn resolve_star_split_alone_equals_linear_recipe() {
2282 let split = resolve(&parse("*/16").unwrap(), 0, 1600).unwrap();
2283 let linear = resolve(&parse("linear:16").unwrap(), 0, 1600).unwrap();
2284 assert_eq!(split, linear);
2285 }
2286
2287 #[test]
2288 fn resolve_star_split_no_remainder_rejected() {
2289 let err = resolve(&parse("100%,*/4").unwrap(), 0, 1000).unwrap_err();
2290 assert!(err.contains("no remainder"), "diagnostic: {err}");
2291 }
2292
2293 #[test]
2294 fn resolve_star_split_finer_than_remainder_rejected() {
2295 let err = resolve(&parse("90%,*/200").unwrap(), 0, 1000).unwrap_err();
2296 assert!(err.contains("non-empty"), "diagnostic: {err}");
2297 }
2298
2299 #[test]
2300 fn resolve_tail_indices_continue_from_head() {
2301 let parts = resolve(&parse("50%,*/5").unwrap(), 0, 1000).unwrap();
2302 assert_eq!(parts.len(), 6);
2303 for (i, p) in parts.iter().enumerate() {
2304 assert_eq!(p.idx, i as u64);
2305 }
2306 }
2307
2308 #[test]
2309 fn split_evenly_boundaries_monotone_and_exact() {
2310 for (start, end, n) in [
2311 (0u64, 100u64, 7u64),
2312 (5, 5, 1),
2313 (0, 3, 3),
2314 (1000, 10007, 13),
2315 ] {
2316 let chunks = split_evenly(start, end, n);
2317 assert_eq!(chunks.len(), n as usize);
2318 assert_eq!(chunks[0].0, start);
2319 assert_eq!(chunks[n as usize - 1].1, end);
2320 for w in chunks.windows(2) {
2321 assert_eq!(w[0].1, w[1].0);
2322 }
2323 let total: u64 = chunks.iter().map(|(s, e)| e - s).sum();
2324 assert_eq!(total, end - start);
2325 }
2326 }
2327
2328 #[test]
2331 fn parse_tolerates_whitespace_in_lists() {
2332 let spec = parse(" 2% , 10% , *% ").unwrap();
2333 assert_eq!(
2334 spec,
2335 PartitionSpec::delta_list(vec![Bound::Pct(2.0), Bound::Pct(10.0), Bound::Star])
2336 );
2337 }
2338
2339 #[test]
2342 fn partition_roundtrips_through_value_ext() {
2343 let p = Partition {
2344 idx: 2,
2345 count: 4,
2346 start_ord: 100,
2347 end_ord: 500,
2348 start_pct: 10.0,
2349 end_pct: 50.0,
2350 base_extent: 1000,
2351 };
2352 let v = Value::from_partition(p);
2353 let recovered = v.as_partition().expect("downcast");
2354 assert_eq!(recovered.idx, 2);
2355 assert_eq!(recovered.start_ord, 100);
2356 assert_eq!(recovered.end_ord, 500);
2357 assert_eq!(recovered.cardinality(), 400);
2358 }
2359
2360 #[test]
2361 fn partition_spec_roundtrips_through_value_ext() {
2362 let spec = parse("fib:5").unwrap();
2363 let v = Value::from_partition_spec(spec);
2364 let recovered = v.as_partition_spec().expect("downcast");
2365 match &recovered.chunking {
2367 Chunking::DeltaList { deltas } => assert_eq!(deltas.len(), 5),
2368 other => panic!("expected DeltaList, got {other:?}"),
2369 }
2370 }
2371
2372 #[test]
2373 fn partition_list_roundtrips_through_value_ext() {
2374 let spec = parse("linear:4").unwrap();
2375 let parts = resolve(&spec, 0, 1000).unwrap();
2376 let v = Value::from_partition_list(parts);
2377 let recovered = v.as_partition_list().expect("downcast");
2378 assert_eq!(recovered.len(), 4);
2379 assert_eq!(recovered.as_slice()[0].start_ord, 0);
2380 assert_eq!(recovered.as_slice()[3].end_ord, 1000);
2381 }
2382
2383 #[test]
2384 fn non_partition_value_downcast_returns_none() {
2385 let v = Value::U64(42);
2386 assert!(v.as_partition().is_none());
2387 assert!(v.as_partition_spec().is_none());
2388 assert!(v.as_partition_list().is_none());
2389 }
2390
2391 #[test]
2392 fn parse_tolerates_whitespace_in_range() {
2393 let spec = parse(" 0 .. 53 % ").unwrap();
2394 assert_eq!(
2395 spec,
2396 PartitionSpec::single_range(Bound::Ord(0), Bound::Pct(53.0))
2397 );
2398 }
2399
2400 #[test]
2403 fn parse_window_clause() {
2404 let spec = parse("linear:4 in 25%..75%").unwrap();
2405 assert_eq!(spec.window, Some((Bound::Pct(25.0), Bound::Pct(75.0))));
2406 assert_eq!(spec.order, PartitionOrder::Unchanged);
2407 match &spec.chunking {
2408 Chunking::DeltaList { deltas } => assert_eq!(deltas.len(), 4),
2409 other => panic!("expected DeltaList, got {other:?}"),
2410 }
2411 }
2412
2413 #[test]
2414 fn parse_window_requires_range() {
2415 let err = parse("linear:4 in 50%").unwrap_err();
2416 assert!(err.contains("start..end"), "diagnostic: {err}");
2417 }
2418
2419 #[test]
2420 fn parse_window_requires_sized_bounds() {
2421 let err = parse("linear:4 in 0..*").unwrap_err();
2422 assert!(err.contains("sized"), "diagnostic: {err}");
2423 }
2424
2425 #[test]
2426 fn parse_window_clause_position_errors() {
2427 assert!(parse("in 0..50%").unwrap_err().contains("chunking spec"));
2428 assert!(parse("linear:4 in").unwrap_err().contains("window range"));
2429 assert!(
2430 parse("linear:2 in 0..50% in 0..10%")
2431 .unwrap_err()
2432 .contains("at most one")
2433 );
2434 }
2435
2436 #[test]
2437 fn resolve_windowed_chunking_is_window_relative() {
2438 let parts = resolve(&parse("linear:4 in 20%..100%").unwrap(), 0, 1000).unwrap();
2442 let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2443 assert_eq!(
2444 bounds,
2445 vec![(200, 400), (400, 600), (600, 800), (800, 1000)]
2446 );
2447 }
2448
2449 #[test]
2450 fn resolve_windowed_form1_composes() {
2451 let parts = resolve(&parse("0..50% in 50%..100%").unwrap(), 0, 1000).unwrap();
2453 assert_eq!(parts.len(), 1);
2454 assert_eq!((parts[0].start_ord, parts[0].end_ord), (500, 750));
2455 }
2456
2457 #[test]
2458 fn resolve_windowed_tail_tokens() {
2459 let parts = resolve(&parse("90%,*/10 in 0..50%").unwrap(), 0, 1000).unwrap();
2463 assert_eq!(parts.len(), 11);
2464 assert_eq!((parts[0].start_ord, parts[0].end_ord), (0, 450));
2465 assert_eq!(parts[10].end_ord, 500);
2466 assert_eq!(parts[1].cardinality(), 5);
2467 }
2468
2469 #[test]
2472 fn parse_finite_repetition_expands() {
2473 let spec = parse("1%x3").unwrap();
2474 assert_eq!(spec, PartitionSpec::delta_list(vec![Bound::Pct(1.0); 3]));
2475 }
2476
2477 #[test]
2478 fn parse_repetition_zero_rejected() {
2479 let err = parse("1%x0").unwrap_err();
2480 assert!(err.contains(">= 1"), "diagnostic: {err}");
2481 }
2482
2483 #[test]
2484 fn parse_repetition_on_tail_rejected() {
2485 assert!(parse("*x3").is_err());
2486 assert!(parse("...x3").is_err());
2487 }
2488
2489 #[test]
2490 fn resolve_repetition_equals_fill_and_split_at_90_10() {
2491 let explicit = resolve(&parse("90%,1%,...").unwrap(), 0, 1000).unwrap();
2494 let repeated = resolve(&parse("90%,1%x10").unwrap(), 0, 1000).unwrap();
2495 assert_eq!(repeated, explicit);
2496 }
2497
2498 #[test]
2501 fn parse_gap_entry() {
2502 let spec = parse("10%,~80%,10%").unwrap();
2503 assert_eq!(
2504 spec,
2505 PartitionSpec::delta_list(vec![
2506 Bound::Pct(10.0),
2507 Bound::Gap(Box::new(Bound::Pct(80.0))),
2508 Bound::Pct(10.0),
2509 ])
2510 );
2511 }
2512
2513 #[test]
2514 fn parse_gap_requires_sized_bound() {
2515 let err = parse("10%,~*").unwrap_err();
2516 assert!(err.contains("sized"), "diagnostic: {err}");
2517 }
2518
2519 #[test]
2520 fn parse_gap_repetition_rejected() {
2521 let err = parse("10%,~10%x3").unwrap_err();
2522 assert!(err.contains("size the gap"), "diagnostic: {err}");
2523 }
2524
2525 #[test]
2526 fn parse_all_gaps_rejected() {
2527 let err = parse("~10%,~20%").unwrap_err();
2528 assert!(err.contains("emits no partitions"), "diagnostic: {err}");
2529 }
2530
2531 #[test]
2532 fn parse_fill_after_gap_rejected() {
2533 let err = parse("5%,~5%,...").unwrap_err();
2534 assert!(err.contains("emit nothing"), "diagnostic: {err}");
2535 }
2536
2537 #[test]
2538 fn resolve_gap_consumes_without_emitting() {
2539 let parts = resolve(&parse("10%,~80%,10%").unwrap(), 0, 1000).unwrap();
2540 let bounds: Vec<(u64, u64, u64)> = parts
2541 .iter()
2542 .map(|p| (p.idx, p.start_ord, p.end_ord))
2543 .collect();
2544 assert_eq!(bounds, vec![(0, 0, 100), (1, 900, 1000)]);
2546 }
2547
2548 #[test]
2549 fn resolve_gap_counts_toward_star_remainder() {
2550 let parts = resolve(&parse("10%,~40%,*").unwrap(), 0, 1000).unwrap();
2552 let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2553 assert_eq!(bounds, vec![(0, 100), (500, 1000)]);
2554 }
2555
2556 #[test]
2559 fn parse_star_shaped_recipe() {
2560 let spec = parse("50%,*/ratios:1,3").unwrap();
2561 match &spec.chunking {
2562 Chunking::DeltaList { deltas } => {
2563 assert_eq!(deltas.len(), 2);
2564 match &deltas[1] {
2565 Bound::StarShaped(w) => {
2566 assert_eq!(w.len(), 2);
2567 assert!((w[0] - 25.0).abs() < 1e-9);
2568 assert!((w[1] - 75.0).abs() < 1e-9);
2569 }
2570 other => panic!("expected StarShaped, got {other:?}"),
2571 }
2572 }
2573 other => panic!("expected DeltaList, got {other:?}"),
2574 }
2575 }
2576
2577 #[test]
2578 fn parse_star_linear_rejected_with_canonical_hint() {
2579 let err = parse("90%,*/linear:4").unwrap_err();
2580 assert!(
2581 err.contains("*/4"),
2582 "diagnostic should point at `*/N`: {err}"
2583 );
2584 }
2585
2586 #[test]
2587 fn resolve_star_shaped_divides_remainder_by_weights() {
2588 let parts = resolve(&parse("50%,*/ratios:1,3").unwrap(), 0, 1000).unwrap();
2590 let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2591 assert_eq!(bounds, vec![(0, 500), (500, 625), (625, 1000)]);
2592 }
2593
2594 #[test]
2595 fn resolve_star_shaped_alone_covers_extent() {
2596 let parts = resolve(&parse("*/fib:3").unwrap(), 0, 600).unwrap();
2597 let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2599 assert_eq!(bounds, vec![(0, 100), (100, 300), (300, 600)]);
2600 }
2601
2602 #[test]
2603 fn resolve_star_shaped_empty_chunk_rejected() {
2604 let err = resolve(&parse("90%,*/ratios:1,1000").unwrap(), 0, 100).unwrap_err();
2606 assert!(err.contains("empty partition"), "diagnostic: {err}");
2607 }
2608
2609 #[test]
2612 fn parse_order_suffix() {
2613 assert_eq!(
2614 parse("fib:5 largest_first").unwrap().order,
2615 PartitionOrder::LargestFirst
2616 );
2617 assert_eq!(
2618 parse("fib:5 smallest_first").unwrap().order,
2619 PartitionOrder::SmallestFirst
2620 );
2621 assert_eq!(parse("fib:5 random").unwrap().order, PartitionOrder::Random);
2622 assert_eq!(
2623 parse("fib:5 unchanged").unwrap().order,
2624 PartitionOrder::Unchanged
2625 );
2626 assert_eq!(parse("fib:5").unwrap().order, PartitionOrder::Unchanged);
2627 }
2628
2629 #[test]
2630 fn parse_unknown_order_rejected() {
2631 let err = parse("fib:5 descend").unwrap_err();
2632 assert!(err.contains("unknown order"), "diagnostic: {err}");
2633 assert!(
2634 err.contains("largest_first"),
2635 "diagnostic should list options: {err}"
2636 );
2637 }
2638
2639 #[test]
2640 fn parse_bare_direction_words_rejected_with_axis_hint() {
2641 let err = parse("fib:5 ascending").unwrap_err();
2645 assert!(err.contains("smallest_first"), "diagnostic: {err}");
2646 assert!(
2647 err.contains("SIZE"),
2648 "diagnostic should name the axis: {err}"
2649 );
2650 let err = parse("fib:5 descending").unwrap_err();
2651 assert!(err.contains("largest_first"), "diagnostic: {err}");
2652 }
2653
2654 #[test]
2655 fn resolve_largest_first_sorts_by_cardinality_keeping_idx() {
2656 let parts = resolve(&parse("fib:5 largest_first").unwrap(), 0, 1000).unwrap();
2657 for w in parts.windows(2) {
2658 assert!(w[0].cardinality() >= w[1].cardinality(), "largest first");
2659 }
2660 assert_eq!(parts[0].idx, 4);
2663 assert_eq!(parts[4].idx, 0);
2664 }
2665
2666 #[test]
2667 fn resolve_smallest_first_is_stable_for_equal_sizes() {
2668 let parts = resolve(&parse("linear:3 smallest_first").unwrap(), 0, 999).unwrap();
2670 let idxs: Vec<u64> = parts.iter().map(|p| p.idx).collect();
2671 assert_eq!(idxs, vec![0, 1, 2]);
2672 }
2673
2674 #[test]
2675 fn resolve_random_is_deterministic_permutation() {
2676 let a = resolve(&parse("linear:8 random").unwrap(), 0, 800).unwrap();
2677 let b = resolve(&parse("linear:8 random").unwrap(), 0, 800).unwrap();
2678 assert_eq!(a, b, "same spec must shuffle identically");
2679 let mut by_idx = a.clone();
2680 by_idx.sort_by_key(|p| p.idx);
2681 let unchanged = resolve(&parse("linear:8").unwrap(), 0, 800).unwrap();
2682 assert_eq!(
2683 by_idx, unchanged,
2684 "shuffle is a permutation of the same partitions"
2685 );
2686 assert_ne!(
2687 a, unchanged,
2688 "8 elements should not shuffle to identity here"
2689 );
2690 }
2691
2692 #[test]
2693 fn display_round_trips_window_and_order() {
2694 let spec = parse("linear:2 in 0..50% largest_first").unwrap();
2695 let shown = ReflectedValue::display(&spec);
2696 assert!(shown.contains("in 0..50%"), "display: {shown}");
2697 assert!(shown.contains("largest_first"), "display: {shown}");
2698 }
2699
2700 #[test]
2703 fn windowed_partitions_label_against_full_base_frame() {
2704 let parts = resolve(&parse("linear:4 in 20%..100%").unwrap(), 0, 1000).unwrap();
2711 let p = &parts[0];
2712 assert_eq!((p.start_ord, p.end_ord), (200, 400));
2713 assert!(
2714 (p.start_pct - 20.0).abs() < 1e-9,
2715 "start_pct: {}",
2716 p.start_pct
2717 );
2718 assert!((p.end_pct - 40.0).abs() < 1e-9, "end_pct: {}", p.end_pct);
2719 assert_eq!(
2720 p.base_extent, 1000,
2721 "base_extent is the full base, not the window"
2722 );
2723 }
2724
2725 #[test]
2726 fn form1_zero_width_slice_rejected() {
2727 let err = resolve(&parse("0..1%").unwrap(), 0, 10).unwrap_err();
2731 assert!(err.contains("zero ordinals"), "diagnostic: {err}");
2732 }
2733
2734 #[test]
2735 fn delta_list_subordinal_recipe_tails_tolerated() {
2736 let parts = resolve(&parse("mul:0.5").unwrap(), 0, 100).unwrap();
2741 assert_eq!(
2742 parts.len(),
2743 11,
2744 "term count is weight-driven, not extent-driven"
2745 );
2746 assert_eq!(parts.last().unwrap().end_ord, 100);
2747 }
2748}
2749
2750pub fn resolve_over(
2768 value: &Value,
2769 extent: u64,
2770 open_extent: bool,
2771) -> Result<Vec<Partition>, String> {
2772 let reproject = |p: &Partition| -> Partition {
2773 if open_extent || p.base_extent == extent || extent == 0 {
2774 return *p;
2775 }
2776 Partition {
2777 idx: p.idx,
2778 count: p.count,
2779 start_ord: ((p.start_pct / 100.0) * extent as f64).round() as u64,
2780 end_ord: ((p.end_pct / 100.0) * extent as f64).round() as u64,
2781 start_pct: p.start_pct,
2782 end_pct: p.end_pct,
2783 base_extent: extent,
2784 }
2785 };
2786 let reject_open = || {
2787 "an open-extent cursor has no extent to resolve a partition spec against; \
2788 resolve the spec against an explicit extent first and declare the cursor `over p`"
2789 .to_string()
2790 };
2791 match value {
2792 Value::None => Ok(Vec::new()),
2793 Value::Str(s) => {
2794 if open_extent {
2795 return Err(reject_open());
2796 }
2797 resolve(&parse(s.as_ref())?, 0, extent)
2798 }
2799 Value::Ext(b) => {
2800 if let Some(p) = value.as_partition() {
2801 Ok(vec![reproject(p)])
2802 } else if let Some(spec) = value.as_partition_spec() {
2803 if open_extent {
2804 return Err(reject_open());
2805 }
2806 resolve(spec, 0, extent)
2807 } else if let Some(list) = value.as_partition_list() {
2808 Ok(list.as_slice().iter().map(reproject).collect())
2809 } else {
2810 Err(format!(
2811 "`over` expression produced an Ext value of type `{}`; expected Partition, PartitionSpec, or PartitionList",
2812 b.type_name()
2813 ))
2814 }
2815 }
2816 other => Err(format!(
2817 "`over` expression produced an unsupported value; expected a spec string or a partition-typed value, got {other:?}"
2818 )),
2819 }
2820}
2821
2822pub fn cursor_extent(
2826 program: &crate::kernel::PolydatProgram,
2827 state: &mut crate::kernel::PolydatState,
2828 schema: &crate::iteration::source::SourceSchema,
2829) -> u64 {
2830 if let Some((start_out, end_out)) = &schema.extent_outputs {
2831 let start = state.pull(program, start_out).as_u64();
2832 let end = state.pull(program, end_out).as_u64();
2833 let extent = end.saturating_sub(start);
2834 return schema.extent_limit.map(|l| extent.min(l)).unwrap_or(extent);
2835 }
2836 schema.extent.unwrap_or(0)
2837}
2838
2839pub fn cursor_over_partitions(
2846 program: &crate::kernel::PolydatProgram,
2847 state: &mut crate::kernel::PolydatState,
2848 schema: &crate::iteration::source::SourceSchema,
2849) -> Result<Vec<Partition>, String> {
2850 if let Some(parts) = &schema.partitions {
2851 return Ok(parts.clone());
2852 }
2853 let Some(raw) = &schema.partition_output else {
2854 return Ok(Vec::new());
2855 };
2856 let value = state.pull(program, raw).clone();
2857 let extent = cursor_extent(program, state, schema);
2858 let open = !matches!(
2859 schema.cursor_kind,
2860 crate::iteration::source::CursorKind::Range
2861 );
2862 resolve_over(&value, extent, open)
2863}
2864
2865pub fn cursor_extent_on(
2868 kernel: &mut dyn crate::kernel::Kernel,
2869 schema: &crate::iteration::source::SourceSchema,
2870) -> u64 {
2871 if let Some((start_out, end_out)) = &schema.extent_outputs {
2872 let start = kernel.pull(start_out).as_u64();
2873 let end = kernel.pull(end_out).as_u64();
2874 let extent = end.saturating_sub(start);
2875 return schema.extent_limit.map(|l| extent.min(l)).unwrap_or(extent);
2876 }
2877 schema.extent.unwrap_or(0)
2878}
2879
2880pub fn cursor_over_partitions_on(
2883 kernel: &mut dyn crate::kernel::Kernel,
2884 schema: &crate::iteration::source::SourceSchema,
2885) -> Result<Vec<Partition>, String> {
2886 if let Some(parts) = &schema.partitions {
2887 return Ok(parts.clone());
2888 }
2889 let Some(raw) = &schema.partition_output else {
2890 return Ok(Vec::new());
2891 };
2892 let value = kernel.pull(raw);
2893 let extent = cursor_extent_on(kernel, schema);
2894 let open = !matches!(
2895 schema.cursor_kind,
2896 crate::iteration::source::CursorKind::Range
2897 );
2898 resolve_over(&value, extent, open)
2899}
2900
2901pub fn cursor_slot_writes(cursor_name: &str, partition: &Partition) -> [(String, Value); 7] {
2906 let slot = |suffix: &str| format!("{cursor_name}__cursor{suffix}");
2907 [
2908 (slot(""), Value::from_partition(*partition)),
2909 (slot("__idx"), Value::U64(partition.idx)),
2910 (
2911 slot("__partition_count"),
2912 Value::U64(partition.count.max(1)),
2913 ),
2914 (slot("__start_pct"), Value::F64(partition.start_pct)),
2915 (slot("__end_pct"), Value::F64(partition.end_pct)),
2916 (slot("__start_ordinal"), Value::U64(partition.start_ord)),
2917 (slot("__end_ordinal"), Value::U64(partition.end_ord)),
2918 ]
2919}
2920
2921pub fn narrow_cursor(
2927 program: &crate::kernel::PolydatProgram,
2928 state: &mut crate::kernel::PolydatState,
2929 cursor_name: &str,
2930 partition: &Partition,
2931) {
2932 for (slot, v) in cursor_slot_writes(cursor_name, partition) {
2933 if let Some(idx) = program.find_input(&slot) {
2934 state.set_input(idx, v);
2935 }
2936 }
2937}
2938
2939#[cfg(test)]
2940mod over_tests {
2941 use super::*;
2942
2943 #[test]
2944 fn resolve_over_string_spec_against_extent() {
2945 let parts = resolve_over(&Value::Str("20%,30%,*".into()), 1000, false).unwrap();
2946 let bounds: Vec<(u64, u64)> = parts.iter().map(|p| (p.start_ord, p.end_ord)).collect();
2947 assert_eq!(bounds, vec![(0, 200), (200, 500), (500, 1000)]);
2948 }
2949
2950 #[test]
2951 fn resolve_over_reprojects_partition_onto_cursor_extent() {
2952 let p = resolve(&parse("50%..100%").unwrap(), 0, 100).unwrap()[0];
2953 let got = resolve_over(&Value::from_partition(p), 1000, false).unwrap();
2954 assert_eq!((got[0].start_ord, got[0].end_ord), (500, 1000));
2955 assert_eq!(got[0].base_extent, 1000);
2956 }
2957
2958 #[test]
2959 fn resolve_over_none_is_empty_and_open_rejects_specs() {
2960 assert!(resolve_over(&Value::None, 10, false).unwrap().is_empty());
2961 assert!(resolve_over(&Value::Str("*/2".into()), 10, true).is_err());
2962 }
2963}