Skip to main content

lore/store/
stats.rs

1//! Usage statistics and remembered placeholder values.
2//!
3//! Kept apart from the definition files on purpose. Definitions are meant to be
4//! tracked in the user's own git repository; counters that change on every
5//! keystroke would make that repository permanently dirty and conflict on every
6//! sync.
7//!
8//! Ranking uses frecency: each use adds a point, and points decay exponentially.
9//! Only the running score and the moment it was last touched are stored, so the
10//! cost is two columns per entry rather than a use history.
11
12use std::collections::{BTreeMap, HashMap};
13use std::fs;
14use std::path::Path;
15use std::time::{Duration, SystemTime, UNIX_EPOCH};
16
17use anyhow::{Context, Result};
18use rusqlite::{Connection, OptionalExtension, params};
19
20/// How long a score takes to halve, in seconds.
21pub const HALF_LIFE: f64 = 14.0 * 24.0 * 60.0 * 60.0;
22
23/// Score a freshly saved command starts at, worth roughly three recent uses.
24///
25/// This is what keeps new entries near the top without a separate "added
26/// today" rule: the head start decays on the same curve as everything else, so
27/// an entry that never gets used slides down instead of falling off a cliff.
28pub const NEW_ENTRY_BONUS: f64 = 3.0;
29
30/// How long to wait for another terminal to finish writing.
31const BUSY_TIMEOUT: Duration = Duration::from_secs(3);
32
33const SCHEMA: &str = "
34CREATE TABLE IF NOT EXISTS usage (
35    id           TEXT PRIMARY KEY,
36    score        REAL NOT NULL,
37    last_used_at INTEGER NOT NULL,
38    pinned       INTEGER NOT NULL DEFAULT 0
39);
40
41CREATE TABLE IF NOT EXISTS param_values (
42    id    TEXT NOT NULL,
43    name  TEXT NOT NULL,
44    value TEXT NOT NULL,
45    PRIMARY KEY (id, name)
46);
47";
48
49/// What ranking needs to know about one entry.
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct Score {
52    pub value: f64,
53    pub pinned: bool,
54}
55
56/// Seconds since the Unix epoch.
57pub fn now() -> i64 {
58    SystemTime::now()
59        .duration_since(UNIX_EPOCH)
60        .map(|d| d.as_secs() as i64)
61        .unwrap_or_default()
62}
63
64/// A score after `elapsed` seconds of decay.
65pub fn decay(score: f64, elapsed: i64) -> f64 {
66    if elapsed <= 0 {
67        return score;
68    }
69    score * 0.5f64.powf(elapsed as f64 / HALF_LIFE)
70}
71
72pub struct Stats {
73    conn: Connection,
74}
75
76impl Stats {
77    /// Opens the statistics database, creating it if it does not exist.
78    pub fn open(path: &Path) -> Result<Self> {
79        if let Some(parent) = path.parent() {
80            fs::create_dir_all(parent)
81                .with_context(|| format!("failed to create {}", parent.display()))?;
82        }
83
84        let conn =
85            Connection::open(path).with_context(|| format!("failed to open {}", path.display()))?;
86        Self::prepare(conn)
87    }
88
89    pub fn in_memory() -> Result<Self> {
90        Self::prepare(Connection::open_in_memory()?)
91    }
92
93    fn prepare(conn: Connection) -> Result<Self> {
94        // Several terminals share this file, so concurrent readers must not
95        // block on a writer and a writer must wait rather than fail.
96        conn.pragma_update_and_check(None, "journal_mode", "WAL", |_| Ok(()))
97            .context("failed to enable write-ahead logging")?;
98        conn.busy_timeout(BUSY_TIMEOUT)?;
99        conn.execute_batch(SCHEMA)
100            .context("failed to initialise the statistics schema")?;
101
102        Ok(Self { conn })
103    }
104
105    /// Records that an entry was selected, decaying its previous score first.
106    pub fn record_use(&self, id: &str, now: i64) -> Result<()> {
107        let previous = self.raw_score(id)?;
108        let score = match previous {
109            Some((score, last_used_at)) => decay(score, now - last_used_at) + 1.0,
110            None => 1.0,
111        };
112        self.write_score(id, score, now)
113    }
114
115    /// Gives a newly saved entry its head start.
116    pub fn record_new(&self, id: &str, now: i64) -> Result<()> {
117        self.write_score(id, NEW_ENTRY_BONUS, now)
118    }
119
120    /// Current scores for every entry that has one, decayed to `now`.
121    ///
122    /// Entries absent from the result have never been used and rank below any
123    /// entry that has.
124    pub fn scores(&self, now: i64) -> Result<HashMap<String, Score>> {
125        let mut statement = self
126            .conn
127            .prepare("SELECT id, score, last_used_at, pinned FROM usage")?;
128
129        let rows = statement.query_map([], |row| {
130            let id: String = row.get(0)?;
131            let score: f64 = row.get(1)?;
132            let last_used_at: i64 = row.get(2)?;
133            let pinned: bool = row.get(3)?;
134            Ok((
135                id,
136                Score {
137                    value: decay(score, now - last_used_at),
138                    pinned,
139                },
140            ))
141        })?;
142
143        Ok(rows.collect::<rusqlite::Result<HashMap<_, _>>>()?)
144    }
145
146    pub fn set_pinned(&self, id: &str, pinned: bool, now: i64) -> Result<()> {
147        self.conn.execute(
148            "INSERT INTO usage (id, score, last_used_at, pinned) VALUES (?1, 0.0, ?2, ?3)
149             ON CONFLICT(id) DO UPDATE SET pinned = ?3",
150            params![id, now, pinned],
151        )?;
152        Ok(())
153    }
154
155    /// Remembers the value last entered for a placeholder, so the next prompt
156    /// arrives pre-filled.
157    pub fn remember_param(&self, id: &str, name: &str, value: &str) -> Result<()> {
158        self.conn.execute(
159            "INSERT INTO param_values (id, name, value) VALUES (?1, ?2, ?3)
160             ON CONFLICT(id, name) DO UPDATE SET value = ?3",
161            params![id, name, value],
162        )?;
163        Ok(())
164    }
165
166    pub fn last_params(&self, id: &str) -> Result<BTreeMap<String, String>> {
167        let mut statement = self
168            .conn
169            .prepare("SELECT name, value FROM param_values WHERE id = ?1")?;
170        let rows = statement.query_map([id], |row| Ok((row.get(0)?, row.get(1)?)))?;
171        Ok(rows.collect::<rusqlite::Result<BTreeMap<_, _>>>()?)
172    }
173
174    /// Drops everything remembered about an entry the user deleted.
175    pub fn forget(&self, id: &str) -> Result<()> {
176        self.conn.execute("DELETE FROM usage WHERE id = ?1", [id])?;
177        self.conn
178            .execute("DELETE FROM param_values WHERE id = ?1", [id])?;
179        Ok(())
180    }
181
182    fn raw_score(&self, id: &str) -> Result<Option<(f64, i64)>> {
183        Ok(self
184            .conn
185            .query_row(
186                "SELECT score, last_used_at FROM usage WHERE id = ?1",
187                [id],
188                |row| Ok((row.get(0)?, row.get(1)?)),
189            )
190            .optional()?)
191    }
192
193    fn write_score(&self, id: &str, score: f64, now: i64) -> Result<()> {
194        self.conn.execute(
195            "INSERT INTO usage (id, score, last_used_at) VALUES (?1, ?2, ?3)
196             ON CONFLICT(id) DO UPDATE SET score = ?2, last_used_at = ?3",
197            params![id, score, now],
198        )?;
199        Ok(())
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    const DAY: i64 = 24 * 60 * 60;
208
209    fn score_of(stats: &Stats, id: &str, now: i64) -> f64 {
210        stats.scores(now).unwrap()[id].value
211    }
212
213    #[test]
214    fn a_score_halves_over_one_half_life() {
215        let halved = decay(4.0, HALF_LIFE as i64);
216        assert!((halved - 2.0).abs() < 1e-6, "expected 2.0, got {halved}");
217    }
218
219    #[test]
220    fn a_score_does_not_grow_backwards_in_time() {
221        assert_eq!(decay(4.0, 0), 4.0);
222        assert_eq!(decay(4.0, -DAY), 4.0);
223    }
224
225    #[test]
226    fn first_use_scores_one_point() {
227        let stats = Stats::in_memory().unwrap();
228        stats.record_use("git.log", 0).unwrap();
229        assert_eq!(score_of(&stats, "git.log", 0), 1.0);
230    }
231
232    #[test]
233    fn repeated_use_accumulates_on_top_of_decay() {
234        let stats = Stats::in_memory().unwrap();
235        let half_life = HALF_LIFE as i64;
236
237        stats.record_use("git.log", 0).unwrap();
238        stats.record_use("git.log", half_life).unwrap();
239
240        // The first point has halved by then, so the running score is 1.5.
241        let score = score_of(&stats, "git.log", half_life);
242        assert!((score - 1.5).abs() < 1e-6, "expected 1.5, got {score}");
243    }
244
245    #[test]
246    fn a_daily_habit_outranks_a_forgotten_burst() {
247        let stats = Stats::in_memory().unwrap();
248        let now = 60 * DAY;
249
250        for day in 0..60 {
251            stats.record_use("daily", day * DAY).unwrap();
252        }
253        for _ in 0..20 {
254            stats.record_use("burst", 0).unwrap();
255        }
256
257        assert!(score_of(&stats, "daily", now) > score_of(&stats, "burst", now));
258    }
259
260    #[test]
261    fn a_new_entry_starts_ahead_but_slides_without_use() {
262        let stats = Stats::in_memory().unwrap();
263
264        stats.record_new("fresh", 0).unwrap();
265        stats.record_use("occasional", 0).unwrap();
266        assert!(score_of(&stats, "fresh", 0) > score_of(&stats, "occasional", 0));
267
268        // Two months later the head start is spent and steady use has won.
269        let later = 60 * DAY;
270        stats.record_use("occasional", 30 * DAY).unwrap();
271        stats.record_use("occasional", 55 * DAY).unwrap();
272        assert!(score_of(&stats, "fresh", later) < score_of(&stats, "occasional", later));
273    }
274
275    #[test]
276    fn an_unused_entry_has_no_score() {
277        let stats = Stats::in_memory().unwrap();
278        stats.record_use("git.log", 0).unwrap();
279        assert!(!stats.scores(0).unwrap().contains_key("docker.ps"));
280    }
281
282    #[test]
283    fn pinning_survives_later_use() {
284        let stats = Stats::in_memory().unwrap();
285        stats.set_pinned("git.log", true, 0).unwrap();
286        stats.record_use("git.log", DAY).unwrap();
287
288        let score = stats.scores(DAY).unwrap()["git.log"];
289        assert!(score.pinned);
290        assert_eq!(score.value, 1.0);
291    }
292
293    #[test]
294    fn pinning_an_unused_entry_does_not_invent_a_score() {
295        let stats = Stats::in_memory().unwrap();
296        stats.set_pinned("git.log", true, 0).unwrap();
297        assert_eq!(score_of(&stats, "git.log", 0), 0.0);
298    }
299
300    #[test]
301    fn remembered_parameters_round_trip_and_overwrite() {
302        let stats = Stats::in_memory().unwrap();
303        stats
304            .remember_param("k8s.logs", "namespace", "dev")
305            .unwrap();
306        stats.remember_param("k8s.logs", "pod", "api-0").unwrap();
307        stats
308            .remember_param("k8s.logs", "namespace", "prod")
309            .unwrap();
310
311        let values = stats.last_params("k8s.logs").unwrap();
312        assert_eq!(values["namespace"], "prod");
313        assert_eq!(values["pod"], "api-0");
314        assert!(stats.last_params("other").unwrap().is_empty());
315    }
316
317    #[test]
318    fn two_connections_can_write_the_same_database() {
319        let path = std::env::temp_dir().join(format!("lore-stats-{}.db", std::process::id()));
320        let _ = fs::remove_file(&path);
321
322        let first = Stats::open(&path).unwrap();
323        let second = Stats::open(&path).unwrap();
324
325        first.record_use("git.log", 0).unwrap();
326        second.record_use("docker.ps", 0).unwrap();
327        first.record_use("docker.ps", DAY).unwrap();
328
329        // Each connection sees the other's writes, and the second use of
330        // docker.ps landed on top of the decayed first one.
331        let scores = second.scores(DAY).unwrap();
332        assert_eq!(scores.len(), 2);
333        assert!((scores["docker.ps"].value - (decay(1.0, DAY) + 1.0)).abs() < 1e-9);
334        assert_eq!(scores["git.log"].value, decay(1.0, DAY));
335
336        drop(first);
337        drop(second);
338        let _ = fs::remove_file(&path);
339    }
340
341    #[test]
342    fn forgetting_an_entry_clears_its_score_and_parameters() {
343        let stats = Stats::in_memory().unwrap();
344        stats.record_use("gone", 0).unwrap();
345        stats.remember_param("gone", "path", "/tmp").unwrap();
346
347        stats.forget("gone").unwrap();
348
349        assert!(stats.scores(0).unwrap().is_empty());
350        assert!(stats.last_params("gone").unwrap().is_empty());
351    }
352}