Skip to main content

pinto/
rank.rs

1//! Lexicographic rank for fractional indexing.
2//!
3//! Backlog order is stored as a string that sorts lexicographically. Because a gap can always be
4//! generated between two ranks, reordering an item only rewrites that one item — neighbors keep
5//! their ranks, so insertion and movement are O(1) writes.
6//!
7//! The internal representation is a base-36 (`0-9a-z`) digit string read as the fraction
8//! `0.d1 d2 …`. In ASCII the ordering `0`–`9` < `a`–`z` matches the numeric order of the digits, so
9//! a plain string comparison is already numeric sort order. Digit values stay in `0..36`, so all
10//! internal arithmetic uses `u8`.
11//!
12//! It is implemented in-house rather than pulling in a crate: in line with the project's
13//! minimal-dependency policy, the logic fits in a few dozen lines of `std`-only code
14//! (see `docs/DESIGN.md`).
15
16use crate::error::{Error, Result};
17use std::fmt;
18use std::str::FromStr;
19
20/// Base-36 radix. Since the digit value falls within the range 0 to 35, it is represented by `u8`.
21const BASE: u8 = 36;
22
23/// Comparable ranks ordered lexicographically.
24///
25/// Generated ranks are always in normal form: a non-empty alphanumeric string with no trailing
26/// `0`. In that form, lexicographic comparison matches the intended rank order; see
27/// [`Rank::parse`] for the validation rules.
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub struct Rank(String);
30
31impl Rank {
32    /// Return the rank string.
33    #[must_use]
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37
38    /// Parse a rank from its string representation.
39    ///
40    /// Conditions for a valid rank (**normal form**):
41    /// - Non-empty and all base-36 alphanumeric characters (`0-9a-z`).
42    /// - **Does not end with `0`**. A trailing zero does not change a base-36
43    ///   fraction (`"1"` and `"10"` both represent 1/36), which would permit
44    ///   multiple strings for the same rank. Disallowing it makes the mapping
45    ///   between rank strings and values one-to-one, so lexicographic comparison
46    ///   always matches numeric order. Leading and middle zeroes remain valid:
47    ///   they contribute to the value (`"01"` ≠ `"1"`).
48    /// - This rule also excludes zero (all digits `0`), which is reserved as the
49    ///   lower endpoint of the open rank interval.
50    ///
51    /// If the condition is not met, [`Error::InvalidRank`].
52    ///
53    /// # Examples
54    ///
55    /// ```
56    /// use pinto::rank::Rank;
57    ///
58    /// let lower = Rank::parse("i").expect("canonical rank");
59    /// let upper = Rank::after(Some(&lower));
60    /// assert!(lower < upper);
61    /// ```
62    pub fn parse(s: &str) -> Result<Rank> {
63        let bytes = s.as_bytes();
64        let valid = !bytes.is_empty()
65            && bytes.iter().all(|&b| digit_value(b).is_some())
66            && bytes.last() != Some(&b'0');
67        if !valid {
68            return Err(Error::InvalidRank(s.to_string()));
69        }
70        Ok(Rank(s.to_string()))
71    }
72
73    /// Return a rank between `lo` and `hi`. `None` represents the open lower or upper endpoint.
74    ///
75    /// Precondition: `lo < hi` (when both are `Some`). The return value `mid` satisfies `lo < mid < hi`.
76    ///
77    /// Returns [`Error::InvalidRank`] when the bounds are not in ascending order.
78    #[must_use = "handle an invalid rank bound"]
79    pub fn between(lo: Option<&Rank>, hi: Option<&Rank>) -> Result<Rank> {
80        if matches!((lo, hi), (Some(l), Some(h)) if l >= h) {
81            let lower = lo.map_or_else(|| "<open>".to_string(), ToString::to_string);
82            let upper = hi.map_or_else(|| "<open>".to_string(), ToString::to_string);
83            return Err(Error::InvalidRank(format!(
84                "between requires lo < hi (lo={lower:?}, hi={upper:?})"
85            )));
86        }
87        Ok(Self::between_unchecked(lo, hi))
88    }
89
90    /// Calculate a midpoint after the caller has established the bounds are valid.
91    fn between_unchecked(lo: Option<&Rank>, hi: Option<&Rank>) -> Rank {
92        let lo_digits: Vec<u8> = lo.map(|r| digits_of(r.as_str())).unwrap_or_default();
93        // Treat `hi = None` as 1.0 and a concrete upper bound as a fraction below 1.0.
94        let (hi_int, hi_digits): (u8, Vec<u8>) = match hi {
95            Some(r) => (0, digits_of(r.as_str())),
96            None => (1, Vec::new()),
97        };
98
99        let (int_sum, frac_sum) = add_frac(&lo_digits, &hi_digits, hi_int);
100        let mut mid = half_frac(int_sum, &frac_sum);
101
102        // Remove trailing zeroes during normalization; they do not change the represented value.
103        while mid.last() == Some(&0) {
104            mid.pop();
105        }
106        Rank(digits_to_string(&mid))
107    }
108
109    /// Return the rank immediately following (greater than) `prev`. If `prev` is `None`, return
110    /// the first rank.
111    ///
112    /// Uses tail-add-only logic and does not go through `between(prev, None)` (midpoint to 1.0).
113    /// The midpoint method halves the difference from 1.0 on each append, which makes repeated
114    /// appends produce increasingly long strings. Instead, compute the shortest rank greater than
115    /// `prev` directly:
116    ///
117    /// - Increase the digits less than `z` (`BASE-1`) by one when looking from the right edge, and truncate the rest.
118    ///   It lexicographically exceeds `prev` by that digit, and the number of digits does not increase (often decreases).
119    /// - Only when all digits are `z` (`prev ≒ 1.0`), add an intermediate digit (`BASE/2`) to the end to extend it.
120    ///
121    /// The incremented digits and added intermediate digits are always non-zero, so the result is always in normal form (no trailing zeros).
122    #[must_use]
123    pub fn after(prev: Option<&Rank>) -> Rank {
124        let digits = match prev {
125            // First rank: 0.5 = 'i' in base-36 (= BASE/2). Matches between(None, None).
126            None => vec![BASE / 2],
127            Some(r) => {
128                let mut d = digits_of(r.as_str());
129                match d.iter().rposition(|&v| v < BASE - 1) {
130                    Some(i) => {
131                        d[i] += 1;
132                        d.truncate(i + 1);
133                        d
134                    }
135                    // All digits are `z`; append an intermediate digit because no increment is possible.
136                    None => {
137                        d.push(BASE / 2);
138                        d
139                    }
140                }
141            }
142        };
143        Rank(digits_to_string(&digits))
144    }
145
146    /// Return the rank immediately preceding (less than) `next`.
147    ///
148    /// This is a convenience wrapper around `between(None, Some(next))`, matching [`Rank::after`]
149    /// so callers do not need to spell out the open lower endpoint.
150    /// - `before(Some(n))` always returns a rank less than `n`.
151    /// - `before(None)` returns the median rank (base-36 `"i"` = 0.5), matching
152    ///   [`Rank::after`] with `None`.
153    #[must_use]
154    pub fn before(next: Option<&Rank>) -> Rank {
155        // `next` is already a valid Rank, and the lower endpoint is open, so these bounds cannot
156        // violate `between`'s precondition.
157        Self::between_unchecked(None, next)
158    }
159
160    /// Generate a short, evenly spaced rank sequence while maintaining the order.
161    ///
162    /// The smallest fixed width that can hold count canonical ranks is chosen.
163    /// Every rank has that width and a non-zero last digit, so it remains in
164    /// normal form. The returned sequence is strictly monotonically increasing
165    /// and leaves roughly equal gaps between adjacent values.
166    ///
167    /// If count is zero, the returned vector is empty.
168    #[must_use]
169    pub fn rebalance(count: usize) -> Vec<Rank> {
170        if count == 0 {
171            return Vec::new();
172        }
173
174        let (width, capacity) = rebalance_layout(count);
175        let count_u128 = count as u128;
176        let mut out = Vec::with_capacity(count);
177        for index in 0..count {
178            // Select the j-th point at floor(j * capacity / (count + 1)).
179            // The open interval leaves one gap at each end.
180            let ordinal = ((index as u128 + 1) * capacity) / (count_u128 + 1);
181            out.push(rank_at_ordinal(ordinal, width));
182        }
183        out
184    }
185
186    /// Return the fixed rank width [`Rank::rebalance`] would use for `count`
187    /// items, without allocating the sequence.
188    ///
189    /// Callers deciding whether a scope needs rebalancing only need to compare
190    /// its current maximum rank length against this width; generating the full
191    /// replacement sequence just to measure it is wasted when the scope is left
192    /// untouched. Returns `0` for `count == 0`, matching the empty sequence.
193    #[must_use]
194    pub fn rebalance_width(count: usize) -> usize {
195        if count == 0 {
196            return 0;
197        }
198        rebalance_layout(count).0
199    }
200}
201
202/// Return the minimal fixed width and the number of canonical ranks available
203/// at that width.
204fn rebalance_layout(count: usize) -> (usize, u128) {
205    let target = count as u128;
206    let mut width = 1;
207    let mut prefix_space = 1u128;
208    loop {
209        let capacity = prefix_space.saturating_mul(u128::from(BASE - 1));
210        if capacity >= target {
211            return (width, capacity);
212        }
213        prefix_space = prefix_space.saturating_mul(u128::from(BASE));
214        width += 1;
215    }
216}
217
218/// Convert an ordinal among fixed-width canonical ranks into a Rank.
219///
220/// The final digit is selected from 1..=35; the preceding digits enumerate
221/// all base-36 prefixes. This covers exactly the canonical strings of a given
222/// width without generating a trailing zero.
223fn rank_at_ordinal(ordinal: u128, width: usize) -> Rank {
224    let last_digit = ordinal % u128::from(BASE - 1) + 1;
225    let mut prefix = ordinal / u128::from(BASE - 1);
226    let mut digits = vec![0; width];
227    for position in (0..width - 1).rev() {
228        digits[position] = (prefix % u128::from(BASE)) as u8;
229        prefix /= u128::from(BASE);
230    }
231    digits[width - 1] = last_digit as u8;
232    Rank(digits_to_string(&digits))
233}
234
235/// Rank-length statistics used to decide whether [`Rank::rebalance`] is needed.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
237pub struct RankStats {
238    /// Number of ranks to be aggregated.
239    pub count: usize,
240    /// Maximum rank length in digits, the main indicator of rank-space expansion.
241    pub max_len: usize,
242    /// Sum of all rank lengths (used to calculate average).
243    pub total_len: usize,
244}
245
246impl RankStats {
247    /// Traverse the rank column and aggregate statistics.
248    pub fn collect<'a, I>(ranks: I) -> RankStats
249    where
250        I: IntoIterator<Item = &'a Rank>,
251    {
252        let mut stats = RankStats::default();
253        for rank in ranks {
254            let len = rank.as_str().len();
255            stats.count += 1;
256            stats.total_len += len;
257            stats.max_len = stats.max_len.max(len);
258        }
259        stats
260    }
261
262    /// Return the average rank length, or `0.0` when no ranks were provided.
263    #[must_use]
264    pub fn average_len(&self) -> f64 {
265        if self.count == 0 {
266            0.0
267        } else {
268            self.total_len as f64 / self.count as f64
269        }
270    }
271
272    /// Return whether the maximum rank length exceeds `max_len_threshold`.
273    #[must_use]
274    pub fn should_rebalance(&self, max_len_threshold: usize) -> bool {
275        self.max_len > max_len_threshold
276    }
277}
278
279impl fmt::Display for Rank {
280    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281        f.write_str(&self.0)
282    }
283}
284
285impl FromStr for Rank {
286    type Err = Error;
287
288    fn from_str(s: &str) -> Result<Self> {
289        Rank::parse(s)
290    }
291}
292
293/// Convert digit value string to normal form string (assuming `0 <= v < 36`).
294fn digits_to_string(digits: &[u8]) -> String {
295    digits.iter().map(|&v| value_digit(v) as char).collect()
296}
297
298/// A single alphanumeric character to a base-36 digit value (`None` if out of range).
299fn digit_value(c: u8) -> Option<u8> {
300    match c {
301        b'0'..=b'9' => Some(c - b'0'),
302        b'a'..=b'z' => Some(c - b'a' + 10),
303        _ => None,
304    }
305}
306
307/// Base-36 digit value to one alphanumeric character (assuming `0 <= v < 36`).
308fn value_digit(v: u8) -> u8 {
309    const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
310    DIGITS[v as usize]
311}
312
313/// Convert regular rank string to digit value string (all digits are valid due to invariant conditions).
314fn digits_of(s: &str) -> Vec<u8> {
315    s.bytes().filter_map(digit_value).collect()
316}
317
318/// Adds the decimal numbers `0.a` and `b_int.b` and returns `(integer part, decimal digits)`.
319///
320/// Each digit is 0 to 35, so `da + db + carry` has a maximum of 35+35+1=71, which fits in `u8`.
321fn add_frac(a: &[u8], b: &[u8], b_int: u8) -> (u8, Vec<u8>) {
322    let n = a.len().max(b.len());
323    let mut frac = vec![0u8; n];
324    let mut carry = 0u8;
325    for i in (0..n).rev() {
326        let da = a.get(i).copied().unwrap_or(0);
327        let db = b.get(i).copied().unwrap_or(0);
328        let sum = da + db + carry;
329        frac[i] = sum % BASE;
330        carry = sum / BASE;
331    }
332    (b_int + carry, frac)
333}
334
335/// Returns the decimal digits of `int.frac` (base-36 decimal) divided by 2.
336///
337/// The integer part of the result is always 0 in the caller's range (`lo < 1`, `hi <= 1`).
338/// `rem * BASE + d` is at most 1*36+35=71 and fits in `u8`.
339fn half_frac(int: u8, frac: &[u8]) -> Vec<u8> {
340    debug_assert!(int / 2 == 0, "rank value out of expected range");
341    let mut out = Vec::with_capacity(frac.len() + 1);
342    let mut rem = int % 2;
343    for &d in frac {
344        let cur = rem * BASE + d;
345        out.push(cur / 2);
346        rem = cur % 2;
347    }
348    // If a fraction (0.5 digit) remains, write the next digit as BASE/2 (divisible).
349    if rem != 0 {
350        out.push(BASE / 2);
351    }
352    out
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use proptest::prelude::*;
359
360    proptest! {
361        #[test]
362        fn generated_ranks_preserve_canonical_order_and_uniqueness(steps in 1usize..200) {
363            let mut ranks = Vec::with_capacity(steps);
364            let mut previous = None;
365            for _ in 0..steps {
366                let next = Rank::after(previous.as_ref());
367                prop_assert!(Rank::parse(next.as_str()).is_ok());
368                if let Some(previous) = previous {
369                    prop_assert!(previous < next);
370                }
371                ranks.push(next.clone());
372                previous = Some(next);
373            }
374            let unique = ranks.iter().collect::<std::collections::HashSet<_>>();
375            prop_assert_eq!(unique.len(), ranks.len());
376        }
377    }
378
379    fn r(s: &str) -> Rank {
380        Rank::parse(s).expect("valid rank")
381    }
382
383    #[test]
384    fn parse_accepts_base36_and_rejects_others() {
385        assert!(Rank::parse("i").is_ok());
386        assert!(Rank::parse("0z9a").is_ok());
387        assert!(matches!(Rank::parse(""), Err(Error::InvalidRank(_))));
388        assert!(matches!(Rank::parse("AB"), Err(Error::InvalidRank(_)))); // Uppercase is invalid.
389        assert!(matches!(Rank::parse("a-b"), Err(Error::InvalidRank(_))));
390        // The number 0 (all 0 digits) is reserved at the end of the lower open interval and is not a valid rank.
391        assert!(matches!(Rank::parse("0"), Err(Error::InvalidRank(_))));
392        assert!(matches!(Rank::parse("000"), Err(Error::InvalidRank(_))));
393        assert!(Rank::parse("01").is_ok()); // A leading zero contributes to the value and is valid.
394    }
395
396    #[test]
397    fn parse_rejects_trailing_zero_for_canonical_form() {
398        //Rejected because trailing 0 does not contribute to the number and allows multiple representations of the same number.
399        // "1" and "10" are both 1/36 and equivalent, but in lexicographical order "1" < "10".
400        // "Dictionary order == numerical order" is broken. Normal form does not have trailing zeros.
401        assert!(Rank::parse("1").is_ok());
402        assert!(matches!(Rank::parse("10"), Err(Error::InvalidRank(_))));
403        assert!(matches!(Rank::parse("100"), Err(Error::InvalidRank(_))));
404        assert!(matches!(Rank::parse("iz0"), Err(Error::InvalidRank(_))));
405        // Normal form (allowed) because the 0 in the middle contributes to the number.
406        assert!(Rank::parse("101").is_ok());
407    }
408
409    #[test]
410    fn generated_ranks_are_always_canonical() {
411        // The product of between / after / before is always in normal form that passes through parse.
412        let a = Rank::between(None, None).expect("open bounds produce a rank");
413        let b = Rank::after(Some(&a));
414        let c = Rank::before(Some(&a));
415        for g in [&a, &b, &c] {
416            assert!(
417                Rank::parse(g.as_str()).is_ok(),
418                "generated rank must be canonical: {g}"
419            );
420        }
421    }
422
423    #[test]
424    fn display_roundtrips() {
425        assert_eq!(r("1i").to_string(), "1i");
426        assert_eq!("1i".parse::<Rank>().unwrap(), r("1i"));
427    }
428
429    #[test]
430    fn first_rank_is_deterministic_middle() {
431        // Midpoint between 0.0 and 1.0 = 0.5 = 'i'(18) in base-36.
432        assert_eq!(
433            Rank::between(None, None).expect("open bounds produce a rank"),
434            r("i")
435        );
436    }
437
438    #[test]
439    fn after_produces_strictly_greater() {
440        let a = Rank::after(None);
441        let b = Rank::after(Some(&a));
442        let c = Rank::after(Some(&b));
443        assert!(a < b, "{a} < {b}");
444        assert!(b < c, "{b} < {c}");
445    }
446
447    #[test]
448    fn before_produces_strictly_smaller() {
449        let a = Rank::between(None, None).expect("open bounds produce a rank");
450        let b = Rank::between(None, Some(&a)).expect("valid ascending bounds");
451        assert!(b < a, "{b} < {a}");
452    }
453
454    #[test]
455    fn before_api_is_symmetric_to_after() {
456        //before(None) is the first rank of the empty sequence (matches after(None)).
457        assert_eq!(Rank::before(None), Rank::after(None));
458        assert_eq!(Rank::before(None), r("i"));
459        // before(Some(n)) is always less than n. Continuous prepend is also strictly monotonically decreasing.
460        let mut next = Rank::before(None);
461        for _ in 0..50 {
462            let prev = Rank::before(Some(&next));
463            assert!(prev < next, "{prev} < {next}");
464            next = prev;
465        }
466    }
467
468    #[test]
469    fn between_is_strictly_ordered() {
470        let lo = r("1");
471        let hi = r("2");
472        let mid = Rank::between(Some(&lo), Some(&hi)).expect("valid ascending bounds");
473        assert!(lo < mid && mid < hi, "{lo} < {mid} < {hi}");
474    }
475
476    #[test]
477    fn between_rejects_violated_precondition() {
478        // lo == hi violates the precondition (lo < hi), in every build profile.
479        let r = Rank::between(None, None).expect("open bounds produce a rank"); // "i"
480        let error = Rank::between(Some(&r), Some(&r)).expect_err("equal bounds are rejected");
481        assert!(matches!(error, Error::InvalidRank(message) if message.contains("lo < hi")));
482    }
483
484    #[test]
485    fn repeated_bisection_stays_between_and_unique() {
486        // Even if you insert it between the same two points 100 times, it will always be strictly monotone and will not conflict.
487        let lo = Rank::between(None, None).expect("open bounds produce a rank"); // "i"
488        let hi = Rank::after(Some(&lo)); // > lo
489        let mut left = lo.clone();
490        let mut seen = std::collections::HashSet::new();
491        seen.insert(lo.clone());
492        seen.insert(hi.clone());
493        for n in 0..100 {
494            let mid = Rank::between(Some(&left), Some(&hi)).expect("valid ascending bounds");
495            assert!(left < mid && mid < hi, "iter {n}: {left} < {mid} < {hi}");
496            assert!(seen.insert(mid.clone()), "iter {n}: duplicate {mid}");
497            left = mid;
498        }
499    }
500
501    #[test]
502    fn sequential_appends_sort_in_insertion_order() {
503        // The ranks assigned with append are arranged in the order of insertion.
504        let mut ranks = Vec::new();
505        let mut prev: Option<Rank> = None;
506        for _ in 0..50 {
507            let next = Rank::after(prev.as_ref());
508            ranks.push(next.clone());
509            prev = Some(next);
510        }
511        let mut sorted = ranks.clone();
512        sorted.sort();
513        assert_eq!(ranks, sorted, "append order must equal sorted order");
514        // unique.
515        let unique: std::collections::HashSet<_> = ranks.iter().collect();
516        assert_eq!(unique.len(), ranks.len());
517    }
518
519    #[test]
520    fn append_growth_is_bounded_over_many_appends() {
521        //after() suppresses rank length expansion using tail addition-only logic.
522        // The current between(prev, None) (midpoint to 1.0) method halves the gap each time.
523        // It grows to ~500 digits with 1,000 appends. Imposing a significantly shorter upper limit as a regression guard.
524        let mut prev: Option<Rank> = None;
525        let mut ranks: Vec<Rank> = Vec::with_capacity(1000);
526        let mut max_len = 0usize;
527        for _ in 0..1000 {
528            let next = Rank::after(prev.as_ref());
529            max_len = max_len.max(next.as_str().len());
530            ranks.push(next.clone());
531            prev = Some(next);
532        }
533        // Strictly monotone (order guaranteed) in the order of addition.
534        for w in ranks.windows(2) {
535            assert!(
536                w[0] < w[1],
537                "append must be strictly increasing: {} < {}",
538                w[0],
539                w[1]
540            );
541        }
542        // unique.
543        let unique: std::collections::HashSet<_> = ranks.iter().collect();
544        assert_eq!(unique.len(), ranks.len(), "appended ranks must be unique");
545        // Tail-append-only insertion remains bounded even before an explicit rebalance.
546        assert!(
547            max_len <= 70,
548            "append growth not bounded: max_len = {max_len}"
549        );
550    }
551
552    #[test]
553    fn rebalance_preserves_order_and_shortens_ranks() {
554        //If you insert a large amount into the same section, the rank will become bloated.
555        let lo = Rank::between(None, None).expect("open bounds produce a rank");
556        let hi = Rank::after(Some(&lo));
557        let mut left = lo.clone();
558        let mut bloated = Vec::new();
559        for _ in 0..500 {
560            let mid = Rank::between(Some(&left), Some(&hi)).expect("valid ascending bounds");
561            bloated.push(mid.clone());
562            left = mid;
563        }
564        let bloated_max = bloated.iter().map(|r| r.as_str().len()).max().unwrap();
565
566        // Rebalance: Generate the same number of short ranks and replace them in the same order.
567        let balanced = Rank::rebalance(bloated.len());
568        assert_eq!(balanced.len(), bloated.len(), "same cardinality");
569        // Narrowly monotone & unique (order preserving).
570        for w in balanced.windows(2) {
571            assert!(w[0] < w[1], "rebalanced ranks must be sorted");
572        }
573        let unique: std::collections::HashSet<_> = balanced.iter().collect();
574        assert_eq!(
575            unique.len(),
576            balanced.len(),
577            "rebalanced ranks must be unique"
578        );
579        // Rank length has been significantly shortened.
580        let balanced_max = balanced.iter().map(|r| r.as_str().len()).max().unwrap();
581        assert!(
582            balanced_max < bloated_max,
583            "rebalance must shorten: {balanced_max} < {bloated_max}"
584        );
585        assert!(balanced_max <= 70, "rebalanced max_len = {balanced_max}");
586    }
587
588    #[test]
589    fn rebalance_zero_is_empty() {
590        assert!(Rank::rebalance(0).is_empty());
591    }
592
593    #[test]
594    fn rebalance_width_matches_generated_width() {
595        // The cheap width predicate must agree with the width the full sequence
596        // actually occupies, across fixed-width boundaries (35 -> 1 digit, 36 ->
597        // 2 digits, 36^2 -> 3 digits).
598        for count in [0usize, 1, 2, 35, 36, 37, 1_000, 1_296, 1_297] {
599            let generated = RankStats::collect(Rank::rebalance(count).iter()).max_len;
600            assert_eq!(
601                Rank::rebalance_width(count),
602                generated,
603                "rebalance_width({count}) must equal the generated max width"
604            );
605        }
606    }
607
608    #[test]
609    fn rebalance_uses_minimal_fixed_width_even_spacing() {
610        for (count, expected_width) in [(0, 0), (1, 1), (36, 2), (1_000, 2)] {
611            let ranks = Rank::rebalance(count);
612            let stats = RankStats::collect(ranks.iter());
613
614            assert_eq!(stats.count, count);
615            assert_eq!(stats.max_len, expected_width);
616            assert_eq!(stats.total_len, count * expected_width);
617            assert_eq!(stats.average_len(), expected_width as f64);
618            assert!(
619                ranks
620                    .iter()
621                    .all(|rank| rank.as_str().len() == expected_width),
622                "all ranks for {count} items must use one fixed width"
623            );
624            assert!(
625                ranks.iter().all(|rank| Rank::parse(rank.as_str()).is_ok()),
626                "all rebalanced ranks must remain canonical"
627            );
628            assert!(
629                ranks.windows(2).all(|window| window[0] < window[1]),
630                "rebalanced ranks must be strictly increasing"
631            );
632        }
633    }
634
635    #[test]
636    fn rank_stats_reports_max_and_average() {
637        //Maximum and average rank lengths can be aggregated and used for rebalancing decisions.
638        let ranks = vec![r("i"), r("zz"), r("1")]; // Lengths: 1, 2, 1.
639        let stats = RankStats::collect(&ranks);
640        assert_eq!(stats.count, 3);
641        assert_eq!(stats.max_len, 2);
642        assert_eq!(stats.total_len, 4);
643        assert!((stats.average_len() - 4.0 / 3.0).abs() < 1e-9);
644        // Judgment based on threshold.
645        assert!(stats.should_rebalance(1), "max_len 2 > 1");
646        assert!(!stats.should_rebalance(2), "max_len 2 not > 2");
647        // Empty set.
648        let empty = RankStats::collect(std::iter::empty::<&Rank>());
649        assert_eq!(empty.count, 0);
650        assert_eq!(empty.average_len(), 0.0);
651        assert!(!empty.should_rebalance(0));
652    }
653
654    #[test]
655    fn generated_ranks_have_no_trailing_zero() {
656        for _ in 0..20 {
657            let a = Rank::between(None, None).expect("open bounds produce a rank");
658            let b = Rank::between(None, Some(&a)).expect("valid ascending bounds");
659            assert_ne!(b.as_str().as_bytes().last(), Some(&b'0'));
660        }
661    }
662
663    /// Deterministic LCG so the property test needs no external crate and never flakes.
664    /// (Numerical Recipes constants; only the high bits are used for better distribution.)
665    struct Lcg(u64);
666    impl Lcg {
667        fn next_usize(&mut self, bound: usize) -> usize {
668            self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
669            ((self.0 >> 33) as usize) % bound
670        }
671    }
672
673    #[test]
674    fn between_property_holds_for_random_insertions() {
675        // Property: inserting a rank between two adjacent, ascending ranks always yields a value
676        // strictly between them (`lo < mid < hi`), in canonical form, that never collides with an
677        // existing rank. Repeatedly bisecting random gaps stresses `between` far beyond the fixed
678        // cases above and guards the self-implemented algorithm against regressions.
679        let mut rng = Lcg(0x9E3779B97F4A7C15);
680        // Start from three ordered ranks so there is always an interior gap to bisect.
681        let mut ranks = vec![Rank::after(None)];
682        ranks.push(Rank::after(Some(&ranks[0])));
683        ranks.push(Rank::after(Some(&ranks[1])));
684
685        for _ in 0..2_000 {
686            // Pick a random adjacent pair and insert between it.
687            let i = rng.next_usize(ranks.len() - 1);
688            let (lo, hi) = (ranks[i].clone(), ranks[i + 1].clone());
689            let mid = Rank::between(Some(&lo), Some(&hi)).expect("valid ascending bounds");
690
691            assert!(lo < mid && mid < hi, "expected {lo} < {mid} < {hi}");
692            assert_eq!(
693                Rank::parse(mid.as_str()),
694                Ok(mid.clone()),
695                "{mid} canonical"
696            );
697            ranks.insert(i + 1, mid);
698        }
699
700        // The whole sequence is still strictly increasing and free of duplicates.
701        for pair in ranks.windows(2) {
702            assert!(pair[0] < pair[1], "order broke: {} !< {}", pair[0], pair[1]);
703        }
704        let unique: std::collections::BTreeSet<&str> = ranks.iter().map(Rank::as_str).collect();
705        assert_eq!(unique.len(), ranks.len(), "ranks must stay unique");
706    }
707}