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
187/// Return the minimal fixed width and the number of canonical ranks available
188/// at that width.
189fn rebalance_layout(count: usize) -> (usize, u128) {
190    let target = count as u128;
191    let mut width = 1;
192    let mut prefix_space = 1u128;
193    loop {
194        let capacity = prefix_space.saturating_mul(u128::from(BASE - 1));
195        if capacity >= target {
196            return (width, capacity);
197        }
198        prefix_space = prefix_space.saturating_mul(u128::from(BASE));
199        width += 1;
200    }
201}
202
203/// Convert an ordinal among fixed-width canonical ranks into a Rank.
204///
205/// The final digit is selected from 1..=35; the preceding digits enumerate
206/// all base-36 prefixes. This covers exactly the canonical strings of a given
207/// width without generating a trailing zero.
208fn rank_at_ordinal(ordinal: u128, width: usize) -> Rank {
209    let last_digit = ordinal % u128::from(BASE - 1) + 1;
210    let mut prefix = ordinal / u128::from(BASE - 1);
211    let mut digits = vec![0; width];
212    for position in (0..width - 1).rev() {
213        digits[position] = (prefix % u128::from(BASE)) as u8;
214        prefix /= u128::from(BASE);
215    }
216    digits[width - 1] = last_digit as u8;
217    Rank(digits_to_string(&digits))
218}
219
220/// Rank-length statistics used to decide whether [`Rank::rebalance`] is needed.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
222pub struct RankStats {
223    /// Number of ranks to be aggregated.
224    pub count: usize,
225    /// Maximum rank length in digits, the main indicator of rank-space expansion.
226    pub max_len: usize,
227    /// Sum of all rank lengths (used to calculate average).
228    pub total_len: usize,
229}
230
231impl RankStats {
232    /// Traverse the rank column and aggregate statistics.
233    pub fn collect<'a, I>(ranks: I) -> RankStats
234    where
235        I: IntoIterator<Item = &'a Rank>,
236    {
237        let mut stats = RankStats::default();
238        for rank in ranks {
239            let len = rank.as_str().len();
240            stats.count += 1;
241            stats.total_len += len;
242            stats.max_len = stats.max_len.max(len);
243        }
244        stats
245    }
246
247    /// Return the average rank length, or `0.0` when no ranks were provided.
248    #[must_use]
249    pub fn average_len(&self) -> f64 {
250        if self.count == 0 {
251            0.0
252        } else {
253            self.total_len as f64 / self.count as f64
254        }
255    }
256
257    /// Return whether the maximum rank length exceeds `max_len_threshold`.
258    #[must_use]
259    pub fn should_rebalance(&self, max_len_threshold: usize) -> bool {
260        self.max_len > max_len_threshold
261    }
262}
263
264impl fmt::Display for Rank {
265    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266        f.write_str(&self.0)
267    }
268}
269
270impl FromStr for Rank {
271    type Err = Error;
272
273    fn from_str(s: &str) -> Result<Self> {
274        Rank::parse(s)
275    }
276}
277
278/// Convert digit value string to normal form string (assuming `0 <= v < 36`).
279fn digits_to_string(digits: &[u8]) -> String {
280    digits.iter().map(|&v| value_digit(v) as char).collect()
281}
282
283/// A single alphanumeric character to a base-36 digit value (`None` if out of range).
284fn digit_value(c: u8) -> Option<u8> {
285    match c {
286        b'0'..=b'9' => Some(c - b'0'),
287        b'a'..=b'z' => Some(c - b'a' + 10),
288        _ => None,
289    }
290}
291
292/// Base-36 digit value to one alphanumeric character (assuming `0 <= v < 36`).
293fn value_digit(v: u8) -> u8 {
294    const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
295    DIGITS[v as usize]
296}
297
298/// Convert regular rank string to digit value string (all digits are valid due to invariant conditions).
299fn digits_of(s: &str) -> Vec<u8> {
300    s.bytes().filter_map(digit_value).collect()
301}
302
303/// Adds the decimal numbers `0.a` and `b_int.b` and returns `(integer part, decimal digits)`.
304///
305/// Each digit is 0 to 35, so `da + db + carry` has a maximum of 35+35+1=71, which fits in `u8`.
306fn add_frac(a: &[u8], b: &[u8], b_int: u8) -> (u8, Vec<u8>) {
307    let n = a.len().max(b.len());
308    let mut frac = vec![0u8; n];
309    let mut carry = 0u8;
310    for i in (0..n).rev() {
311        let da = a.get(i).copied().unwrap_or(0);
312        let db = b.get(i).copied().unwrap_or(0);
313        let sum = da + db + carry;
314        frac[i] = sum % BASE;
315        carry = sum / BASE;
316    }
317    (b_int + carry, frac)
318}
319
320/// Returns the decimal digits of `int.frac` (base-36 decimal) divided by 2.
321///
322/// The integer part of the result is always 0 in the caller's range (`lo < 1`, `hi <= 1`).
323/// `rem * BASE + d` is at most 1*36+35=71 and fits in `u8`.
324fn half_frac(int: u8, frac: &[u8]) -> Vec<u8> {
325    debug_assert!(int / 2 == 0, "rank value out of expected range");
326    let mut out = Vec::with_capacity(frac.len() + 1);
327    let mut rem = int % 2;
328    for &d in frac {
329        let cur = rem * BASE + d;
330        out.push(cur / 2);
331        rem = cur % 2;
332    }
333    // If a fraction (0.5 digit) remains, write the next digit as BASE/2 (divisible).
334    if rem != 0 {
335        out.push(BASE / 2);
336    }
337    out
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use proptest::prelude::*;
344
345    proptest! {
346        #[test]
347        fn generated_ranks_preserve_canonical_order_and_uniqueness(steps in 1usize..200) {
348            let mut ranks = Vec::with_capacity(steps);
349            let mut previous = None;
350            for _ in 0..steps {
351                let next = Rank::after(previous.as_ref());
352                prop_assert!(Rank::parse(next.as_str()).is_ok());
353                if let Some(previous) = previous {
354                    prop_assert!(previous < next);
355                }
356                ranks.push(next.clone());
357                previous = Some(next);
358            }
359            let unique = ranks.iter().collect::<std::collections::HashSet<_>>();
360            prop_assert_eq!(unique.len(), ranks.len());
361        }
362    }
363
364    fn r(s: &str) -> Rank {
365        Rank::parse(s).expect("valid rank")
366    }
367
368    #[test]
369    fn parse_accepts_base36_and_rejects_others() {
370        assert!(Rank::parse("i").is_ok());
371        assert!(Rank::parse("0z9a").is_ok());
372        assert!(matches!(Rank::parse(""), Err(Error::InvalidRank(_))));
373        assert!(matches!(Rank::parse("AB"), Err(Error::InvalidRank(_)))); // Uppercase is invalid.
374        assert!(matches!(Rank::parse("a-b"), Err(Error::InvalidRank(_))));
375        // The number 0 (all 0 digits) is reserved at the end of the lower open interval and is not a valid rank.
376        assert!(matches!(Rank::parse("0"), Err(Error::InvalidRank(_))));
377        assert!(matches!(Rank::parse("000"), Err(Error::InvalidRank(_))));
378        assert!(Rank::parse("01").is_ok()); // A leading zero contributes to the value and is valid.
379    }
380
381    #[test]
382    fn parse_rejects_trailing_zero_for_canonical_form() {
383        //Rejected because trailing 0 does not contribute to the number and allows multiple representations of the same number.
384        // "1" and "10" are both 1/36 and equivalent, but in lexicographical order "1" < "10".
385        // "Dictionary order == numerical order" is broken. Normal form does not have trailing zeros.
386        assert!(Rank::parse("1").is_ok());
387        assert!(matches!(Rank::parse("10"), Err(Error::InvalidRank(_))));
388        assert!(matches!(Rank::parse("100"), Err(Error::InvalidRank(_))));
389        assert!(matches!(Rank::parse("iz0"), Err(Error::InvalidRank(_))));
390        // Normal form (allowed) because the 0 in the middle contributes to the number.
391        assert!(Rank::parse("101").is_ok());
392    }
393
394    #[test]
395    fn generated_ranks_are_always_canonical() {
396        // The product of between / after / before is always in normal form that passes through parse.
397        let a = Rank::between(None, None).expect("open bounds produce a rank");
398        let b = Rank::after(Some(&a));
399        let c = Rank::before(Some(&a));
400        for g in [&a, &b, &c] {
401            assert!(
402                Rank::parse(g.as_str()).is_ok(),
403                "generated rank must be canonical: {g}"
404            );
405        }
406    }
407
408    #[test]
409    fn display_roundtrips() {
410        assert_eq!(r("1i").to_string(), "1i");
411        assert_eq!("1i".parse::<Rank>().unwrap(), r("1i"));
412    }
413
414    #[test]
415    fn first_rank_is_deterministic_middle() {
416        // Midpoint between 0.0 and 1.0 = 0.5 = 'i'(18) in base-36.
417        assert_eq!(
418            Rank::between(None, None).expect("open bounds produce a rank"),
419            r("i")
420        );
421    }
422
423    #[test]
424    fn after_produces_strictly_greater() {
425        let a = Rank::after(None);
426        let b = Rank::after(Some(&a));
427        let c = Rank::after(Some(&b));
428        assert!(a < b, "{a} < {b}");
429        assert!(b < c, "{b} < {c}");
430    }
431
432    #[test]
433    fn before_produces_strictly_smaller() {
434        let a = Rank::between(None, None).expect("open bounds produce a rank");
435        let b = Rank::between(None, Some(&a)).expect("valid ascending bounds");
436        assert!(b < a, "{b} < {a}");
437    }
438
439    #[test]
440    fn before_api_is_symmetric_to_after() {
441        //before(None) is the first rank of the empty sequence (matches after(None)).
442        assert_eq!(Rank::before(None), Rank::after(None));
443        assert_eq!(Rank::before(None), r("i"));
444        // before(Some(n)) is always less than n. Continuous prepend is also strictly monotonically decreasing.
445        let mut next = Rank::before(None);
446        for _ in 0..50 {
447            let prev = Rank::before(Some(&next));
448            assert!(prev < next, "{prev} < {next}");
449            next = prev;
450        }
451    }
452
453    #[test]
454    fn between_is_strictly_ordered() {
455        let lo = r("1");
456        let hi = r("2");
457        let mid = Rank::between(Some(&lo), Some(&hi)).expect("valid ascending bounds");
458        assert!(lo < mid && mid < hi, "{lo} < {mid} < {hi}");
459    }
460
461    #[test]
462    fn between_rejects_violated_precondition() {
463        // lo == hi violates the precondition (lo < hi), in every build profile.
464        let r = Rank::between(None, None).expect("open bounds produce a rank"); // "i"
465        let error = Rank::between(Some(&r), Some(&r)).expect_err("equal bounds are rejected");
466        assert!(matches!(error, Error::InvalidRank(message) if message.contains("lo < hi")));
467    }
468
469    #[test]
470    fn repeated_bisection_stays_between_and_unique() {
471        // Even if you insert it between the same two points 100 times, it will always be strictly monotone and will not conflict.
472        let lo = Rank::between(None, None).expect("open bounds produce a rank"); // "i"
473        let hi = Rank::after(Some(&lo)); // > lo
474        let mut left = lo.clone();
475        let mut seen = std::collections::HashSet::new();
476        seen.insert(lo.clone());
477        seen.insert(hi.clone());
478        for n in 0..100 {
479            let mid = Rank::between(Some(&left), Some(&hi)).expect("valid ascending bounds");
480            assert!(left < mid && mid < hi, "iter {n}: {left} < {mid} < {hi}");
481            assert!(seen.insert(mid.clone()), "iter {n}: duplicate {mid}");
482            left = mid;
483        }
484    }
485
486    #[test]
487    fn sequential_appends_sort_in_insertion_order() {
488        // The ranks assigned with append are arranged in the order of insertion.
489        let mut ranks = Vec::new();
490        let mut prev: Option<Rank> = None;
491        for _ in 0..50 {
492            let next = Rank::after(prev.as_ref());
493            ranks.push(next.clone());
494            prev = Some(next);
495        }
496        let mut sorted = ranks.clone();
497        sorted.sort();
498        assert_eq!(ranks, sorted, "append order must equal sorted order");
499        // unique.
500        let unique: std::collections::HashSet<_> = ranks.iter().collect();
501        assert_eq!(unique.len(), ranks.len());
502    }
503
504    #[test]
505    fn append_growth_is_bounded_over_many_appends() {
506        //after() suppresses rank length expansion using tail addition-only logic.
507        // The current between(prev, None) (midpoint to 1.0) method halves the gap each time.
508        // It grows to ~500 digits with 1,000 appends. Imposing a significantly shorter upper limit as a regression guard.
509        let mut prev: Option<Rank> = None;
510        let mut ranks: Vec<Rank> = Vec::with_capacity(1000);
511        let mut max_len = 0usize;
512        for _ in 0..1000 {
513            let next = Rank::after(prev.as_ref());
514            max_len = max_len.max(next.as_str().len());
515            ranks.push(next.clone());
516            prev = Some(next);
517        }
518        // Strictly monotone (order guaranteed) in the order of addition.
519        for w in ranks.windows(2) {
520            assert!(
521                w[0] < w[1],
522                "append must be strictly increasing: {} < {}",
523                w[0],
524                w[1]
525            );
526        }
527        // unique.
528        let unique: std::collections::HashSet<_> = ranks.iter().collect();
529        assert_eq!(unique.len(), ranks.len(), "appended ranks must be unique");
530        // Tail-append-only insertion remains bounded even before an explicit rebalance.
531        assert!(
532            max_len <= 70,
533            "append growth not bounded: max_len = {max_len}"
534        );
535    }
536
537    #[test]
538    fn rebalance_preserves_order_and_shortens_ranks() {
539        //If you insert a large amount into the same section, the rank will become bloated.
540        let lo = Rank::between(None, None).expect("open bounds produce a rank");
541        let hi = Rank::after(Some(&lo));
542        let mut left = lo.clone();
543        let mut bloated = Vec::new();
544        for _ in 0..500 {
545            let mid = Rank::between(Some(&left), Some(&hi)).expect("valid ascending bounds");
546            bloated.push(mid.clone());
547            left = mid;
548        }
549        let bloated_max = bloated.iter().map(|r| r.as_str().len()).max().unwrap();
550
551        // Rebalance: Generate the same number of short ranks and replace them in the same order.
552        let balanced = Rank::rebalance(bloated.len());
553        assert_eq!(balanced.len(), bloated.len(), "same cardinality");
554        // Narrowly monotone & unique (order preserving).
555        for w in balanced.windows(2) {
556            assert!(w[0] < w[1], "rebalanced ranks must be sorted");
557        }
558        let unique: std::collections::HashSet<_> = balanced.iter().collect();
559        assert_eq!(
560            unique.len(),
561            balanced.len(),
562            "rebalanced ranks must be unique"
563        );
564        // Rank length has been significantly shortened.
565        let balanced_max = balanced.iter().map(|r| r.as_str().len()).max().unwrap();
566        assert!(
567            balanced_max < bloated_max,
568            "rebalance must shorten: {balanced_max} < {bloated_max}"
569        );
570        assert!(balanced_max <= 70, "rebalanced max_len = {balanced_max}");
571    }
572
573    #[test]
574    fn rebalance_zero_is_empty() {
575        assert!(Rank::rebalance(0).is_empty());
576    }
577
578    #[test]
579    fn rebalance_uses_minimal_fixed_width_even_spacing() {
580        for (count, expected_width) in [(0, 0), (1, 1), (36, 2), (1_000, 2)] {
581            let ranks = Rank::rebalance(count);
582            let stats = RankStats::collect(ranks.iter());
583
584            assert_eq!(stats.count, count);
585            assert_eq!(stats.max_len, expected_width);
586            assert_eq!(stats.total_len, count * expected_width);
587            assert_eq!(stats.average_len(), expected_width as f64);
588            assert!(
589                ranks
590                    .iter()
591                    .all(|rank| rank.as_str().len() == expected_width),
592                "all ranks for {count} items must use one fixed width"
593            );
594            assert!(
595                ranks.iter().all(|rank| Rank::parse(rank.as_str()).is_ok()),
596                "all rebalanced ranks must remain canonical"
597            );
598            assert!(
599                ranks.windows(2).all(|window| window[0] < window[1]),
600                "rebalanced ranks must be strictly increasing"
601            );
602        }
603    }
604
605    #[test]
606    fn rank_stats_reports_max_and_average() {
607        //Maximum and average rank lengths can be aggregated and used for rebalancing decisions.
608        let ranks = vec![r("i"), r("zz"), r("1")]; // Lengths: 1, 2, 1.
609        let stats = RankStats::collect(&ranks);
610        assert_eq!(stats.count, 3);
611        assert_eq!(stats.max_len, 2);
612        assert_eq!(stats.total_len, 4);
613        assert!((stats.average_len() - 4.0 / 3.0).abs() < 1e-9);
614        // Judgment based on threshold.
615        assert!(stats.should_rebalance(1), "max_len 2 > 1");
616        assert!(!stats.should_rebalance(2), "max_len 2 not > 2");
617        // Empty set.
618        let empty = RankStats::collect(std::iter::empty::<&Rank>());
619        assert_eq!(empty.count, 0);
620        assert_eq!(empty.average_len(), 0.0);
621        assert!(!empty.should_rebalance(0));
622    }
623
624    #[test]
625    fn generated_ranks_have_no_trailing_zero() {
626        for _ in 0..20 {
627            let a = Rank::between(None, None).expect("open bounds produce a rank");
628            let b = Rank::between(None, Some(&a)).expect("valid ascending bounds");
629            assert_ne!(b.as_str().as_bytes().last(), Some(&b'0'));
630        }
631    }
632
633    /// Deterministic LCG so the property test needs no external crate and never flakes.
634    /// (Numerical Recipes constants; only the high bits are used for better distribution.)
635    struct Lcg(u64);
636    impl Lcg {
637        fn next_usize(&mut self, bound: usize) -> usize {
638            self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
639            ((self.0 >> 33) as usize) % bound
640        }
641    }
642
643    #[test]
644    fn between_property_holds_for_random_insertions() {
645        // Property: inserting a rank between two adjacent, ascending ranks always yields a value
646        // strictly between them (`lo < mid < hi`), in canonical form, that never collides with an
647        // existing rank. Repeatedly bisecting random gaps stresses `between` far beyond the fixed
648        // cases above and guards the self-implemented algorithm against regressions.
649        let mut rng = Lcg(0x9E3779B97F4A7C15);
650        // Start from three ordered ranks so there is always an interior gap to bisect.
651        let mut ranks = vec![Rank::after(None)];
652        ranks.push(Rank::after(Some(&ranks[0])));
653        ranks.push(Rank::after(Some(&ranks[1])));
654
655        for _ in 0..2_000 {
656            // Pick a random adjacent pair and insert between it.
657            let i = rng.next_usize(ranks.len() - 1);
658            let (lo, hi) = (ranks[i].clone(), ranks[i + 1].clone());
659            let mid = Rank::between(Some(&lo), Some(&hi)).expect("valid ascending bounds");
660
661            assert!(lo < mid && mid < hi, "expected {lo} < {mid} < {hi}");
662            assert_eq!(
663                Rank::parse(mid.as_str()),
664                Ok(mid.clone()),
665                "{mid} canonical"
666            );
667            ranks.insert(i + 1, mid);
668        }
669
670        // The whole sequence is still strictly increasing and free of duplicates.
671        for pair in ranks.windows(2) {
672            assert!(pair[0] < pair[1], "order broke: {} !< {}", pair[0], pair[1]);
673        }
674        let unique: std::collections::BTreeSet<&str> = ranks.iter().map(Rank::as_str).collect();
675        assert_eq!(unique.len(), ranks.len(), "ranks must stay unique");
676    }
677}