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