Skip to main content

vector_core/
emoji_usage.rs

1//! Per-account emoji "frecency" (most-used) tracker — SQL-backed.
2//!
3//! One row per distinct emoji in `emoji_usage` (PK `(kind,id)`); a reuse is an
4//! in-place UPSERT, so the table holds at most one row per emoji and never grows
5//! on repeat use. It's also pruned to the top `MAX_ENTRIES` by score.
6//!
7//! ## The log-space score (why writes and reads are both O(log n))
8//!
9//! Each use at time `t` adds `2^((t - EPOCH)/half_life)` points to the emoji's
10//! `score`. To rank "right now" you'd multiply every score by the same factor
11//! `2^(-(now - EPOCH)/half_life)` — but a uniform factor doesn't change order,
12//! so ranking is just `ORDER BY score DESC` off an index — no per-row decay math
13//! at read time. Newer uses are worth exponentially more, so old favourites fade
14//! automatically as tastes change.
15//!
16//! A soft cap (`score = MIN(score + inc, CAP·inc)`, enforced in the UPSERT) stops
17//! a one-off burst from pinning an emoji forever: a burst saturates at the cap,
18//! and a newer use — worth more per point — overtakes it within a half-life or
19//! two. `ranked()` converts the stored log-space score back to a normalised
20//! 0..CAP "effective" value for the UI.
21//!
22//! The row shape (kind, id, url, score, last_used) is also the cross-device sync
23//! payload: log-space scores from two devices are directly additive (sum =
24//! combined usage). Sync itself is future work.
25
26use serde::{Deserialize, Serialize};
27
28/// Compact, indexable kind discriminant.
29const KIND_UNICODE: i64 = 0;
30const KIND_CUSTOM: i64 = 1;
31
32/// Half-life: an emoji unused for this long loses half its standing.
33const HALF_LIFE_SECS: f64 = 21.0 * 24.0 * 3600.0; // 21 days
34/// Reference epoch (2025-01-01 UTC) keeps the log-space exponent small. It grows
35/// ~17/year and f64 overflows near 2^1024, so this is safe for ~50 years before
36/// a rebase would ever be needed; `bump` guards against a non-finite increment.
37const EPOCH_SECS: f64 = 1_735_689_600.0;
38/// Effective-score ceiling — a burst saturates here instead of running away.
39const SCORE_CAP: f64 = 25.0;
40/// Keep the table bounded; pruned to the top-N by score after writes.
41const MAX_ENTRIES: i64 = 256;
42
43/// A ranked usage row handed to the frontend. `score` is the normalised
44/// effective (decayed-to-now) value in `0..=SCORE_CAP`, not the raw log-space
45/// store — so the UI can treat it as a plain 0..25 weight.
46#[derive(Serialize, Deserialize, Clone, Debug)]
47pub struct EmojiUsageEntry {
48    pub kind: String,
49    pub id: String,
50    #[serde(skip_serializing_if = "Option::is_none", default)]
51    pub url: Option<String>,
52    pub score: f64,
53}
54
55/// One use to record (a sent message's distinct emojis, or a reaction).
56#[derive(Serialize, Deserialize, Clone, Debug)]
57pub struct EmojiUse {
58    pub kind: String,
59    pub id: String,
60    #[serde(default)]
61    pub url: Option<String>,
62}
63
64fn now_secs() -> f64 {
65    std::time::SystemTime::now()
66        .duration_since(std::time::UNIX_EPOCH)
67        .map(|d| d.as_secs() as f64)
68        .unwrap_or(0.0)
69}
70
71fn kind_code(kind: &str) -> i64 {
72    if kind == "custom" { KIND_CUSTOM } else { KIND_UNICODE }
73}
74
75fn kind_label(code: i64) -> &'static str {
76    if code == KIND_CUSTOM { "custom" } else { "unicode" }
77}
78
79/// Log-space points one use contributes at `now`: `2^((now - EPOCH)/half_life)`.
80fn use_increment(now: f64) -> f64 {
81    ((now - EPOCH_SECS) / HALF_LIFE_SECS).exp2()
82}
83
84/// Factor that converts a stored log-space score to its effective value at `now`.
85fn decay_to_now(now: f64) -> f64 {
86    (-(now - EPOCH_SECS) / HALF_LIFE_SECS).exp2()
87}
88
89// ============================================================================
90// Writes
91// ============================================================================
92
93/// Record several uses in one transaction (one sent message → one DB write).
94pub fn bump_batch(uses: &[EmojiUse]) -> Result<(), String> {
95    if uses.is_empty() {
96        return Ok(());
97    }
98    let now = now_secs();
99    let inc = use_increment(now);
100    // Decades-out overflow guard: never write a non-finite score.
101    if !inc.is_finite() {
102        return Ok(());
103    }
104    let cap = SCORE_CAP * inc;
105    let now_i = now as i64;
106
107    let conn = crate::db::get_write_connection_guard_static()?;
108    let tx = conn
109        .unchecked_transaction()
110        .map_err(|e| format!("emoji_usage tx: {e}"))?;
111    {
112        let mut up = tx
113            .prepare_cached(
114                "INSERT INTO emoji_usage (kind, id, url, score, last_used)
115                 VALUES (?1, ?2, ?3, ?4, ?5)
116                 ON CONFLICT(kind, id) DO UPDATE SET
117                     score     = MIN(score + ?4, ?6),
118                     last_used = ?5,
119                     url       = COALESCE(?3, url)",
120            )
121            .map_err(|e| e.to_string())?;
122        for u in uses {
123            if u.id.is_empty() {
124                continue;
125            }
126            up.execute(rusqlite::params![kind_code(&u.kind), u.id, u.url, inc, now_i, cap])
127                .map_err(|e| format!("emoji_usage upsert: {e}"))?;
128        }
129        // Bound the table: drop everything outside the top-N by score. Cheap —
130        // the score index orders the subquery and a small table makes it a no-op
131        // until the cap is actually exceeded.
132        tx.execute(
133            "DELETE FROM emoji_usage
134             WHERE (kind, id) NOT IN (
135                 SELECT kind, id FROM emoji_usage ORDER BY score DESC LIMIT ?1
136             )",
137            rusqlite::params![MAX_ENTRIES],
138        )
139        .map_err(|e| format!("emoji_usage prune: {e}"))?;
140    }
141    tx.commit().map_err(|e| format!("emoji_usage commit: {e}"))?;
142    Ok(())
143}
144
145/// Record one use (reactions; single emoji).
146pub fn bump(kind: &str, id: &str, url: Option<&str>) -> Result<(), String> {
147    if id.is_empty() {
148        return Ok(());
149    }
150    bump_batch(&[EmojiUse {
151        kind: kind.to_string(),
152        id: id.to_string(),
153        url: url.map(|s| s.to_string()),
154    }])
155}
156
157// ============================================================================
158// Reads
159// ============================================================================
160
161/// Ranked usage, highest frecency first. `limit` caps the set (`None` = all).
162pub fn ranked(limit: Option<usize>) -> Vec<EmojiUsageEntry> {
163    let now = now_secs();
164    let decay = decay_to_now(now);
165    let lim: i64 = limit.map(|l| l as i64).unwrap_or(-1); // SQLite: LIMIT -1 = no cap
166
167    let conn = match crate::db::get_db_connection_guard_static() {
168        Ok(c) => c,
169        Err(_) => return Vec::new(),
170    };
171    let mut stmt = match conn
172        .prepare_cached("SELECT kind, id, url, score FROM emoji_usage ORDER BY score DESC LIMIT ?1")
173    {
174        Ok(s) => s,
175        Err(_) => return Vec::new(),
176    };
177    let rows = stmt.query_map(rusqlite::params![lim], |row| {
178        let kind_c: i64 = row.get(0)?;
179        let id: String = row.get(1)?;
180        let url: Option<String> = row.get(2)?;
181        let raw: f64 = row.get(3)?;
182        Ok(EmojiUsageEntry {
183            kind: kind_label(kind_c).to_string(),
184            id,
185            url,
186            score: raw * decay, // normalise log-space → effective 0..=CAP
187        })
188    });
189    match rows {
190        Ok(iter) => iter.filter_map(|r| r.ok()).collect(),
191        Err(_) => Vec::new(),
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    const DAY: f64 = 24.0 * 3600.0;
200    const T0: f64 = EPOCH_SECS + 365.0 * DAY; // ~1 year past the epoch
201
202    /// Pure simulation of the stored log-space score for a sequence of use
203    /// times (applies the per-write soft cap), so the algorithm is testable
204    /// without a DB. Ranking compares these at a common `now`, and the uniform
205    /// decay factor cancels — so comparing raw scores IS the ranking.
206    fn simulate(times: &[f64]) -> f64 {
207        let mut score = 0.0f64;
208        for &t in times {
209            let inc = use_increment(t);
210            score = (score + inc).min(SCORE_CAP * inc);
211        }
212        score
213    }
214
215    #[test]
216    fn kind_code_label_round_trip() {
217        assert_eq!(kind_code("unicode"), KIND_UNICODE);
218        assert_eq!(kind_code("custom"), KIND_CUSTOM);
219        assert_eq!(kind_label(KIND_UNICODE), "unicode");
220        assert_eq!(kind_label(KIND_CUSTOM), "custom");
221        // Unknown kinds default to unicode rather than panicking.
222        assert_eq!(kind_code("???"), KIND_UNICODE);
223    }
224
225    #[test]
226    fn increment_is_finite_and_monotonic_this_era() {
227        let a = use_increment(T0);
228        let b = use_increment(T0 + 30.0 * DAY);
229        assert!(a.is_finite() && b.is_finite());
230        assert!(b > a, "a later use must be worth more in log-space");
231    }
232
233    #[test]
234    fn effective_of_one_use_is_one() {
235        // A single use, decayed back to its own time, is worth exactly 1.
236        let s = simulate(&[T0]);
237        let eff = s * decay_to_now(T0);
238        assert!((eff - 1.0).abs() < 1e-9, "got {eff}");
239    }
240
241    #[test]
242    fn burst_saturates_at_cap() {
243        let s = simulate(&[T0; 1000]);
244        let eff = s * decay_to_now(T0);
245        assert!((eff - SCORE_CAP).abs() < 1e-6, "burst effective {eff} should hit the cap");
246    }
247
248    #[test]
249    fn a_burst_does_not_pin_forever_new_favourite_overtakes() {
250        // The user's worry: paste one emoji 1000× — does everything else need
251        // 1000 uses to win? No. The burst caps; a newer steady habit overtakes.
252        let burst = simulate(&[T0; 1000]);
253        let mut new_times = Vec::new();
254        for d in 0..14 {
255            new_times.push(T0 + (30.0 + d as f64) * DAY);
256        }
257        let fresh = simulate(&new_times);
258        // Compared at a common instant the decay factor cancels, so raw score
259        // order IS the rank: the 14-use recent favourite beats the 1000× burst.
260        assert!(fresh > burst, "fresh {fresh} should outrank burst {burst}");
261    }
262
263    #[test]
264    fn recent_use_outranks_an_equal_older_one() {
265        let older = simulate(&[T0, T0 + DAY, T0 + 2.0 * DAY]);
266        let newer = simulate(&[T0 + 10.0 * DAY, T0 + 11.0 * DAY, T0 + 12.0 * DAY]);
267        assert!(newer > older, "same use count, more recent should rank higher");
268    }
269}