Skip to main content

graph_r/
ttl.rs

1// Author: spicyzhug@gmail.com
2// Copyright: lituus-io, all rights reserved
3//
4//! Adaptive freshness. Every document node carries a revalidation interval
5//! that grows geometrically while the source is observed unchanged and is cut
6//! sharply when it changes — integer math with explicit clamps, so replay
7//! and tests are bit-deterministic. Important nodes (percentile `PageRank`)
8//! are checked more often via a permille bias on the effective interval.
9
10/// The outcome of one revalidation observation.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum Outcome {
13    /// Source unchanged (304, or identical content hash).
14    Unchanged,
15    /// Source content changed.
16    Changed,
17    /// Transient failure (network, 5xx); retry soon, do not penalize.
18    Error,
19    /// Source is gone (404/403/401); the node is tombstoned.
20    Gone,
21}
22
23impl Outcome {
24    /// On-disk tag.
25    #[must_use]
26    pub fn as_tag(self) -> u8 {
27        match self {
28            Self::Unchanged => 0,
29            Self::Changed => 1,
30            Self::Error => 2,
31            Self::Gone => 3,
32        }
33    }
34
35    /// Decode an on-disk tag (unknown tags degrade to `Error`, the neutral
36    /// outcome, rather than failing replay).
37    #[must_use]
38    pub fn from_tag(tag: u8) -> Self {
39        match tag {
40            0 => Self::Unchanged,
41            1 => Self::Changed,
42            3 => Self::Gone,
43            _ => Self::Error,
44        }
45    }
46}
47
48/// Adaptive-TTL policy. All fields are settable; defaults suit documentation
49/// corpora (hours-scale freshness, month-scale ceiling).
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub struct TtlConfig {
52    /// Interval assigned to a newly ingested node, seconds.
53    pub base_s: u32,
54    /// Floor, seconds.
55    pub min_s: u32,
56    /// Ceiling, seconds.
57    pub max_s: u32,
58    /// Growth ratio numerator (applied per consecutive `Unchanged`).
59    pub grow_num: u32,
60    /// Growth ratio denominator.
61    pub grow_den: u32,
62    /// Cut ratio numerator (applied on `Changed`).
63    pub cut_num: u32,
64    /// Cut ratio denominator.
65    pub cut_den: u32,
66    /// How strongly importance shortens the effective interval, in permille:
67    /// a top-ranked node's interval is scaled by `(1000 - bias) / 1000`.
68    pub importance_bias_permille: u32,
69}
70
71impl Default for TtlConfig {
72    fn default() -> Self {
73        Self {
74            base_s: 21_600,   // 6 h
75            min_s: 900,       // 15 min
76            max_s: 2_592_000, // 30 d
77            grow_num: 3,
78            grow_den: 2,
79            cut_num: 1,
80            cut_den: 4,
81            importance_bias_permille: 500,
82        }
83    }
84}
85
86impl TtlConfig {
87    /// The next stored interval after observing `outcome` with the current
88    /// `interval_s`. `Gone` keeps the interval (the node is tombstoned and
89    /// leaves the schedule); `Error` schedules a floor-interval retry.
90    #[must_use]
91    pub fn next_interval(&self, interval_s: u32, outcome: Outcome) -> u32 {
92        let clamp =
93            |v: u64| -> u32 { v.clamp(u64::from(self.min_s), u64::from(self.max_s)) as u32 };
94        match outcome {
95            Outcome::Unchanged => clamp(
96                u64::from(interval_s) * u64::from(self.grow_num) / u64::from(self.grow_den.max(1)),
97            ),
98            Outcome::Changed => clamp(
99                u64::from(interval_s) * u64::from(self.cut_num) / u64::from(self.cut_den.max(1)),
100            ),
101            Outcome::Error => self.min_s,
102            Outcome::Gone => interval_s,
103        }
104    }
105
106    /// When the node is next due, given its rank percentile in permille.
107    /// Higher rank ⇒ shorter effective interval, down to `(1000-bias)/1000`.
108    #[must_use]
109    pub fn next_check_at_ms(&self, fetched_at_ms: u64, interval_s: u32, rank_permille: u16) -> u64 {
110        let bias = u64::from(self.importance_bias_permille.min(1000));
111        let mult = 1000 - bias * u64::from(rank_permille.min(1000)) / 1000;
112        fetched_at_ms.saturating_add(u64::from(interval_s) * 1000 * mult / 1000)
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn grows_on_unchanged_until_ceiling() {
122        let cfg = TtlConfig::default();
123        let mut iv = cfg.base_s;
124        let mut prev = 0;
125        for _ in 0..64 {
126            iv = cfg.next_interval(iv, Outcome::Unchanged);
127            assert!(iv >= prev, "monotone growth");
128            assert!(iv <= cfg.max_s);
129            prev = iv;
130        }
131        assert_eq!(iv, cfg.max_s, "parks at the ceiling");
132    }
133
134    #[test]
135    fn changed_cuts_and_error_floors() {
136        let cfg = TtlConfig::default();
137        let grown = cfg.next_interval(cfg.max_s, Outcome::Unchanged);
138        let cut = cfg.next_interval(grown, Outcome::Changed);
139        assert!(cut < grown);
140        assert!(cut >= cfg.min_s);
141        assert_eq!(cfg.next_interval(grown, Outcome::Error), cfg.min_s);
142        assert_eq!(cfg.next_interval(grown, Outcome::Gone), grown);
143    }
144
145    #[test]
146    fn importance_shortens_the_effective_interval() {
147        let cfg = TtlConfig::default();
148        let nobody = cfg.next_check_at_ms(0, 1000, 0);
149        let star = cfg.next_check_at_ms(0, 1000, 1000);
150        assert_eq!(nobody, 1_000_000);
151        assert_eq!(star, 500_000, "top rank halves the interval at bias 500");
152        assert!(cfg.next_check_at_ms(0, 1000, 500) < nobody);
153    }
154
155    #[test]
156    fn outcome_tags_round_trip() {
157        for o in [Outcome::Unchanged, Outcome::Changed, Outcome::Error, Outcome::Gone] {
158            assert_eq!(Outcome::from_tag(o.as_tag()), o);
159        }
160        assert_eq!(Outcome::from_tag(200), Outcome::Error);
161    }
162}