vector_core/
emoji_usage.rs1use serde::{Deserialize, Serialize};
27
28const KIND_UNICODE: i64 = 0;
30const KIND_CUSTOM: i64 = 1;
31
32const HALF_LIFE_SECS: f64 = 21.0 * 24.0 * 3600.0; const EPOCH_SECS: f64 = 1_735_689_600.0;
38const SCORE_CAP: f64 = 25.0;
40const MAX_ENTRIES: i64 = 256;
42
43#[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#[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
79fn use_increment(now: f64) -> f64 {
81 ((now - EPOCH_SECS) / HALF_LIFE_SECS).exp2()
82}
83
84fn decay_to_now(now: f64) -> f64 {
86 (-(now - EPOCH_SECS) / HALF_LIFE_SECS).exp2()
87}
88
89pub 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 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 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
145pub 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
157pub 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); 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, })
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; 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 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 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 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 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}