Skip to main content

ucal_core/
tier.rs

1//! The tier grid (§4, Rule G) and tier naming (Rule N).
2//!
3//! Rule G: tiers are the powers `5^(5k)`, indexed relative to the beat, so
4//! `T[k] = 5^(60 + 5k)`. Each tier is exactly five base-5 digits — 3125 units of
5//! the tier below. The consequences are the design: a timestamp is the tick count
6//! written in base 5 and grouped in fives, truncation *is* rounding, prefix
7//! comparison *is* chronological comparison.
8//!
9//! Rule N: a tier's canonical identity is its **exponent**. Names come from a
10//! locale table and are display-and-parse aliases only. Nothing in this module
11//! decides behaviour from a name.
12//!
13//! §13.5 requires the tier table, the locale table and the documentation table to
14//! come from one source of truth. That source is [`GRID`] plus [`NAMED`]; the
15//! table in the RFC's §4.1 and Appendix B is generated from it, which is how the
16//! imprecise seconds column in Appendix B (delta D-A3) stops being possible.
17
18use crate::backend::{TickInt, Ticks};
19use crate::error::{Code, Result, TimeError};
20
21/// Digits per tier. Each tier is `5^5 = 3125` units of the tier below.
22pub const GROUP_BASE: u16 = 3125;
23
24/// Base-5 digits per tier group.
25pub const DIGITS_PER_TIER: u32 = 5;
26
27/// Exponent of the base tier: the beat is `5^60` (D-2).
28pub const BEAT_EXPONENT: u32 = 60;
29
30/// Lowest tier index. `T-12 = 5^0` is one tick — the resolution floor (G2, N10).
31pub const K_MIN: i8 = -12;
32
33/// Highest tier index. `T32 = 5^220` is 511 bits, the largest power of five the
34/// 512-bit domain holds, so the grid cannot extend further without widening the
35/// domain — and Rule B makes the width a wire-format commitment.
36pub const K_MAX: i8 = 32;
37
38/// Number of tiers in the grid.
39pub const TIER_COUNT: usize = (K_MAX as isize - K_MIN as isize + 1) as usize;
40
41/// A tier index, relative to the beat.
42#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
43pub struct Tier(i8);
44
45impl Tier {
46    /// The base tier, `5^60`, ~46.762 ms.
47    pub const BEAT: Tier = Tier(0);
48    /// One tick, `5^0`. The finest addressable interval (G2).
49    pub const TICK: Tier = Tier(K_MIN);
50    /// `5^85`, ~441.607 Myr.
51    pub const DEEP: Tier = Tier(5);
52    /// `5^80`, ~141.314 kyr.
53    pub const DRIFT: Tier = Tier(4);
54    /// `5^75`, ~45.221 yr.
55    pub const SPAN: Tier = Tier(3);
56    /// `5^70`, ~5.285 d.
57    pub const SWEEP: Tier = Tier(2);
58    /// `5^65`, ~146.130 s.
59    pub const ARC: Tier = Tier(1);
60    /// `5^55`, ~14.964 us.
61    pub const FLICKER: Tier = Tier(-1);
62    /// `5^50`, ~4.788 ns.
63    pub const GLINT: Tier = Tier(-2);
64    /// `5^45`, ~1.532 ps.
65    pub const SPARK: Tier = Tier(-3);
66
67    /// Construct from a tier index, rejecting indices outside the grid.
68    pub const fn new(k: i8) -> Result<Tier> {
69        if k < K_MIN || k > K_MAX {
70            Err(TimeError::with_context(
71                Code::E0080,
72                "tier index outside [-12, 32]",
73            ))
74        } else {
75            Ok(Tier(k))
76        }
77    }
78
79    /// Construct from a power-of-five exponent, which must lie on the grid.
80    pub const fn from_exponent(exponent: u32) -> Result<Tier> {
81        if exponent % DIGITS_PER_TIER != 0 {
82            return Err(TimeError::with_context(
83                Code::E0080,
84                "exponent is not a multiple of 5, so it is not on the 5^(5k) grid",
85            ));
86        }
87        // (exponent - 60) / 5, computed without going negative in unsigned space.
88        let k = if exponent >= BEAT_EXPONENT {
89            ((exponent - BEAT_EXPONENT) / DIGITS_PER_TIER) as i64
90        } else {
91            -(((BEAT_EXPONENT - exponent) / DIGITS_PER_TIER) as i64)
92        };
93        if k < K_MIN as i64 || k > K_MAX as i64 {
94            return Err(TimeError::with_context(
95                Code::E0080,
96                "exponent outside the profile grid",
97            ));
98        }
99        Ok(Tier(k as i8))
100    }
101
102    /// The tier index.
103    pub const fn index(self) -> i8 {
104        self.0
105    }
106
107    /// The power-of-five exponent. This is the tier's canonical identity (Rule N).
108    pub const fn exponent(self) -> u32 {
109        // k >= K_MIN = -12 guarantees 60 + 5k >= 0.
110        (BEAT_EXPONENT as i64 + DIGITS_PER_TIER as i64 * self.0 as i64) as u32
111    }
112
113    /// Whether this is the tick tier, where truncation is the identity.
114    pub const fn is_tick(self) -> bool {
115        self.0 == K_MIN
116    }
117
118    /// The next coarser tier, or `None` at the top of the grid.
119    pub const fn coarser(self) -> Option<Tier> {
120        if self.0 >= K_MAX {
121            None
122        } else {
123            Some(Tier(self.0 + 1))
124        }
125    }
126
127    /// The next finer tier, or `None` at the tick.
128    pub const fn finer(self) -> Option<Tier> {
129        if self.0 <= K_MIN {
130            None
131        } else {
132            Some(Tier(self.0 - 1))
133        }
134    }
135
136    /// The tier's magnitude in ticks: `5^(60 + 5k)`.
137    pub fn ticks(self) -> Ticks {
138        <Ticks as TickInt>::pow5(self.exponent())
139            .expect("tier grid is bounded so that every tier fits the domain")
140    }
141
142    /// Every tier, coarsest first.
143    pub fn all_descending() -> impl Iterator<Item = Tier> {
144        (K_MIN..=K_MAX).rev().map(Tier)
145    }
146
147    /// Every tier, finest first.
148    pub fn all_ascending() -> impl Iterator<Item = Tier> {
149        (K_MIN..=K_MAX).map(Tier)
150    }
151}
152
153impl core::fmt::Display for Tier {
154    /// Renders as `T<k>`, the notation Rule N requires implementations to accept
155    /// wherever a name is accepted.
156    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
157        write!(f, "T{}", self.0)
158    }
159}
160
161/// The stable identity of a named tier. Locale tables map these keys to display
162/// strings (Appendix D); the key itself is never localised.
163#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
164#[non_exhaustive]
165pub enum TierName {
166    /// T5
167    Deep,
168    /// T4
169    Drift,
170    /// T3
171    Span,
172    /// T2
173    Sweep,
174    /// T1
175    Arc,
176    /// T0
177    Beat,
178    /// T-1
179    Flicker,
180    /// T-2
181    Glint,
182    /// T-3
183    Spark,
184    /// T-12
185    Tick,
186}
187
188impl TierName {
189    /// The stable key, used in locale tables and `--json` output.
190    pub const fn key(self) -> &'static str {
191        match self {
192            TierName::Deep => "deep",
193            TierName::Drift => "drift",
194            TierName::Span => "span",
195            TierName::Sweep => "sweep",
196            TierName::Arc => "arc",
197            TierName::Beat => "beat",
198            TierName::Flicker => "flicker",
199            TierName::Glint => "glint",
200            TierName::Spark => "spark",
201            TierName::Tick => "tick",
202        }
203    }
204}
205
206/// The single source of truth for tier naming (§13.5, Appendix D).
207///
208/// Tiers absent from this table are unnamed and addressed by index — D-20 makes
209/// that a deliberate choice, and Rule N makes naming them a locale change rather
210/// than a specification change.
211pub const NAMED: &[(i8, TierName)] = &[
212    (5, TierName::Deep),
213    (4, TierName::Drift),
214    (3, TierName::Span),
215    (2, TierName::Sweep),
216    (1, TierName::Arc),
217    (0, TierName::Beat),
218    (-1, TierName::Flicker),
219    (-2, TierName::Glint),
220    (-3, TierName::Spark),
221    (-12, TierName::Tick),
222];
223
224/// The name of a tier, if it has one.
225pub fn name_of(tier: Tier) -> Option<TierName> {
226    let mut i = 0;
227    while i < NAMED.len() {
228        if NAMED[i].0 == tier.index() {
229            return Some(NAMED[i].1);
230        }
231        i += 1;
232    }
233    None
234}
235
236/// The tier a key names, if any. Accepts only the stable key; locale aliases are
237/// resolved by the locale table, not here.
238pub fn tier_of_key(key: &str) -> Option<Tier> {
239    NAMED
240        .iter()
241        .find(|(_, n)| n.key() == key)
242        .map(|(k, _)| Tier(*k))
243}
244
245/// The materialised grid: `5^(60 + 5k)` for every `k`, ascending.
246///
247/// Built on demand rather than as a `static`, because on the `bigint` backend a
248/// `Ticks` is heap-allocated and cannot be a `const`. The default backend's
249/// values are all `const`-constructible; §13.5's requirement is that the table
250/// be *generated*, which it is — from [`Tier::exponent`], never transcribed.
251pub struct TierTable {
252    ticks: [Option<Ticks>; TIER_COUNT],
253}
254
255impl TierTable {
256    /// Materialise the whole grid.
257    pub fn build() -> TierTable {
258        let mut ticks: [Option<Ticks>; TIER_COUNT] = core::array::from_fn(|_| None);
259        for k in K_MIN..=K_MAX {
260            let idx = (k as isize - K_MIN as isize) as usize;
261            ticks[idx] = Some(Tier(k).ticks());
262        }
263        TierTable { ticks }
264    }
265
266    /// The magnitude of a tier in ticks.
267    pub fn get(&self, tier: Tier) -> &Ticks {
268        let idx = (tier.index() as isize - K_MIN as isize) as usize;
269        self.ticks[idx]
270            .as_ref()
271            .expect("grid is fully populated by build()")
272    }
273
274    /// Number of entries. Always [`TIER_COUNT`].
275    pub fn len(&self) -> usize {
276        TIER_COUNT
277    }
278
279    /// Always false; the grid is never empty. Present to satisfy clippy.
280    pub fn is_empty(&self) -> bool {
281        false
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn grid_has_45_tiers() {
291        assert_eq!(TIER_COUNT, 45);
292        assert_eq!(Tier::all_ascending().count(), 45);
293    }
294
295    #[test]
296    fn exponent_is_canonical_identity() {
297        // Rule N: identity is the exponent, and the round trip must be exact.
298        for t in Tier::all_ascending() {
299            assert_eq!(Tier::from_exponent(t.exponent()).unwrap(), t);
300        }
301        assert_eq!(Tier::BEAT.exponent(), 60);
302        assert_eq!(Tier::TICK.exponent(), 0);
303        assert_eq!(Tier::DEEP.exponent(), 85);
304        assert_eq!(Tier::new(K_MAX).unwrap().exponent(), 220);
305    }
306
307    #[test]
308    fn off_grid_exponents_rejected() {
309        // 61 is not a multiple of 5, so it is not a tier (Rule G).
310        assert_eq!(Tier::from_exponent(61).unwrap_err().code, Code::E0080);
311        // 225 would be T33, past the domain.
312        assert_eq!(Tier::from_exponent(225).unwrap_err().code, Code::E0080);
313    }
314
315    #[test]
316    fn each_tier_is_3125_of_the_one_below() {
317        let table = TierTable::build();
318        let gb = <Ticks as TickInt>::from_u64(GROUP_BASE as u64);
319        for k in (K_MIN + 1)..=K_MAX {
320            let hi = table.get(Tier::new(k).unwrap());
321            let lo = table.get(Tier::new(k - 1).unwrap());
322            assert_eq!(
323                hi,
324                &lo.try_mul(&gb).expect("within domain"),
325                "T{k} is not 3125 x T{}",
326                k - 1
327            );
328        }
329    }
330
331    #[test]
332    fn top_tier_fits_and_next_would_not() {
333        // 5^220 is 511 bits; 5^225 exceeds the 512-bit domain. This is why the
334        // grid stops at T32 (see K_MAX).
335        assert_eq!(<Ticks as TickInt>::pow5(220).unwrap().bit_len(), 511);
336        assert!(<Ticks as TickInt>::pow5(225).is_none());
337    }
338
339    #[test]
340    fn tick_tier_is_one_tick() {
341        assert_eq!(Tier::TICK.ticks(), <Ticks as TickInt>::one());
342        assert!(Tier::TICK.is_tick());
343        assert_eq!(Tier::TICK.finer(), None);
344        assert_eq!(Tier::new(K_MAX).unwrap().coarser(), None);
345    }
346
347    #[test]
348    fn names_are_display_only() {
349        assert_eq!(name_of(Tier::BEAT).unwrap().key(), "beat");
350        assert_eq!(tier_of_key("deep"), Some(Tier::DEEP));
351        // Unnamed tiers are addressable but have no name (D-20).
352        assert_eq!(name_of(Tier::new(6).unwrap()), None);
353        assert_eq!(name_of(Tier::new(-4).unwrap()), None);
354        assert_eq!(tier_of_key("nonexistent"), None);
355    }
356
357    #[test]
358    fn no_duplicate_names() {
359        // Rule N / UCAL-E0011: a collision in the active table is an error.
360        for (i, (_, a)) in NAMED.iter().enumerate() {
361            for (_, b) in NAMED.iter().skip(i + 1) {
362                assert_ne!(a.key(), b.key(), "duplicate tier name key");
363            }
364        }
365    }
366
367    #[test]
368    fn display_uses_index_notation() {
369        assert_eq!(Tier::BEAT.to_string(), "T0");
370        assert_eq!(Tier::DEEP.to_string(), "T5");
371        assert_eq!(Tier::TICK.to_string(), "T-12");
372    }
373}