返回 CodeWhale
file_frecency.rs
根目录 / crates / tui / src / tui / file_frecency.rs
1 //! @-mention frecency tracking (#441).
2 //!
3 //! Records every file the user @-mentions with a timestamp and click count,
4 //! decays the score over time so a file that was hot last week ranks below
5 //! one mentioned 5 minutes ago, and re-orders mention-popup completions by
6 //! the resulting score. Persisted as a single JSONL file at
7 //! `$CODEWHALE_HOME/file-frecency.jsonl` (normally
8 //! `~/.codewhale/file-frecency.jsonl`) so frecency survives restarts.
9 //!
10 //! Append-only on the wire, compacted in memory: the loader replays every
11 //! line into a `HashMap<String, FrecencyEntry>` keyed by repo-relative path,
12 //! folding duplicates into the last record. We cap the in-memory map at
13 //! 1000 entries and evict the lowest-scored on overflow — same heuristic
14 //! the OPENCODE source uses.
15
16 use std::collections::HashMap;
17 use std::fs::OpenOptions;
18 use std::io::Write;
19 use std::path::PathBuf;
20 use std::sync::{Mutex, OnceLock};
21 use std::time::{SystemTime, UNIX_EPOCH};
22
23 use serde::{Deserialize, Serialize};
24
25 /// Hard cap on the number of paths we track (the acceptance criterion for
26 /// #441). Older / lower-scored entries are evicted when the map exceeds
27 /// this.
28 const FRECENCY_CAP: usize = 1000;
29
30 /// Half-life of a frecency score, in seconds. After this many seconds the
31 /// score has decayed to ½ of its peak. 7 days is OPENCODE's default — long
32 /// enough that a commonly-edited file stays sticky across a workweek but
33 /// short enough that yesterday's deep-dive doesn't haunt you forever.
34 const HALF_LIFE_SECS: f64 = 7.0 * 24.0 * 60.0 * 60.0;
35
36 #[derive(Debug, Clone, Serialize, Deserialize)]
37 struct FrecencyRecord {
38 /// Workspace-relative path string.
39 path: String,
40 /// Total mentions over the lifetime of the entry.
41 count: u32,
42 /// Unix timestamp (seconds) of the last mention.
43 last_used: u64,
44 }
45
46 #[derive(Debug, Default)]
47 struct Store {
48 by_path: HashMap<String, FrecencyRecord>,
49 persisted_path: Option<PathBuf>,
50 loaded: bool,
51 }
52
53 fn store() -> &'static Mutex<Store> {
54 static STORE: OnceLock<Mutex<Store>> = OnceLock::new();
55 STORE.get_or_init(|| Mutex::new(Store::default()))
56 }
57
58 fn default_path() -> Option<PathBuf> {
59 // Unit tests exercise mention selection heavily and must never read from or
60 // append to a developer's real Codewhale home. Integration and release QA
61 // processes use an explicit isolated CODEWHALE_HOME instead.
62 #[cfg(test)]
63 {
64 None
65 }
66
67 #[cfg(not(test))]
68 {
69 codewhale_config::codewhale_home()
70 .ok()
71 .map(|home| home.join("file-frecency.jsonl"))
72 }
73 }
74
75 fn now_secs() -> u64 {
76 SystemTime::now()
77 .duration_since(UNIX_EPOCH)
78 .map(|d| d.as_secs())
79 .unwrap_or(0)
80 }
81
82 /// Time-decayed frecency score for a record, in arbitrary units. Mentions
83 /// count linearly; the whole sum is multiplied by an exponential decay
84 /// factor based on time since `last_used`. Records older than ~5 half-lives
85 /// score effectively zero.
86 fn decayed_score(record: &FrecencyRecord, now: u64) -> f64 {
87 let age_secs = now.saturating_sub(record.last_used) as f64;
88 let lambda = std::f64::consts::LN_2 / HALF_LIFE_SECS;
89 (record.count as f64) * (-lambda * age_secs).exp()
90 }
91
92 fn ensure_loaded(store: &mut Store) {
93 if store.loaded {
94 return;
95 }
96 store.loaded = true;
97 let Some(path) = default_path() else {
98 return;
99 };
100 store.persisted_path = Some(path.clone());
101 let Ok(text) = std::fs::read_to_string(&path) else {
102 return;
103 };
104 for line in text.lines() {
105 if line.trim().is_empty() {
106 continue;
107 }
108 let Ok(record) = serde_json::from_str::<FrecencyRecord>(line) else {
109 continue;
110 };
111 store.by_path.insert(record.path.clone(), record);
112 }
113 }
114
115 fn evict_to_cap(store: &mut Store, now: u64) {
116 if store.by_path.len() <= FRECENCY_CAP {
117 return;
118 }
119 let target = FRECENCY_CAP;
120 let mut scored: Vec<(String, f64)> = store
121 .by_path
122 .iter()
123 .map(|(k, v)| (k.clone(), decayed_score(v, now)))
124 .collect();
125 scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
126 let drop_count = store.by_path.len().saturating_sub(target);
127 for (key, _) in scored.iter().take(drop_count) {
128 store.by_path.remove(key);
129 }
130 }
131
132 fn append_record_line(path: &PathBuf, record: &FrecencyRecord) -> std::io::Result<()> {
133 if let Some(parent) = path.parent() {
134 std::fs::create_dir_all(parent)?;
135 }
136 let mut file = OpenOptions::new().create(true).append(true).open(path)?;
137 let line = serde_json::to_string(record).map_err(std::io::Error::other)?;
138 writeln!(file, "{line}")?;
139 Ok(())
140 }
141
142 /// Record one mention of `path` (a workspace-relative path string). Updates
143 /// the in-memory store, persists a single JSONL line, and evicts the lowest-
144 /// scored entry if we just exceeded the cap. Best-effort: I/O failures are
145 /// logged and swallowed — losing a frecency datapoint is never worth
146 /// failing the user's `@` autocomplete.
147 pub fn record_mention(path: &str) {
148 if path.is_empty() {
149 return;
150 }
151 let store = store();
152 let Ok(mut store) = store.lock() else {
153 return;
154 };
155 ensure_loaded(&mut store);
156 let now = now_secs();
157 let entry = store
158 .by_path
159 .entry(path.to_string())
160 .or_insert_with(|| FrecencyRecord {
161 path: path.to_string(),
162 count: 0,
163 last_used: now,
164 });
165 entry.count = entry.count.saturating_add(1);
166 entry.last_used = now;
167 let snapshot = entry.clone();
168 if let Some(persisted_path) = store.persisted_path.clone()
169 && let Err(err) = append_record_line(&persisted_path, &snapshot)
170 {
171 tracing::debug!(target: "frecency", "persist failed: {err}");
172 }
173 evict_to_cap(&mut store, now);
174 }
175
176 /// Re-sort a candidate list by frecency score (highest first), preserving
177 /// the original order for ties so the underlying ranker's choices aren't
178 /// upended. Candidates the store has never seen score zero — they end up
179 /// at the bottom of the sort, which means a one-time mention will start
180 /// floating to the top after first use.
181 #[must_use]
182 pub fn rerank_by_frecency(candidates: Vec<String>) -> Vec<String> {
183 if candidates.len() <= 1 {
184 return candidates;
185 }
186 let store = store();
187 let Ok(mut store) = store.lock() else {
188 return candidates;
189 };
190 ensure_loaded(&mut store);
191 let now = now_secs();
192 let mut scored: Vec<(usize, String, f64)> = candidates
193 .into_iter()
194 .enumerate()
195 .map(|(idx, path)| {
196 let score = store
197 .by_path
198 .get(&path)
199 .map(|r| decayed_score(r, now))
200 .unwrap_or(0.0);
201 (idx, path, score)
202 })
203 .collect();
204 // Stable sort on (-score, original-index): ties keep the underlying
205 // ranker's order.
206 scored.sort_by(|a, b| {
207 b.2.partial_cmp(&a.2)
208 .unwrap_or(std::cmp::Ordering::Equal)
209 .then_with(|| a.0.cmp(&b.0))
210 });
211 scored.into_iter().map(|(_, path, _)| path).collect()
212 }
213
214 #[cfg(test)]
215 mod tests {
216 use super::*;
217
218 /// Recently mentioned paths win against never-mentioned ones; never-mentioned
219 /// preserve their original ranker order.
220 #[test]
221 fn rerank_floats_recent_paths_to_the_top() {
222 // Use the global store; reset its state so we don't leak across tests.
223 let store = super::store();
224 let mut s = store.lock().unwrap();
225 s.by_path.clear();
226 s.loaded = true; // skip on-disk replay
227 s.persisted_path = None; // skip persistence
228 let now = super::now_secs();
229 s.by_path.insert(
230 "src/popular.rs".into(),
231 FrecencyRecord {
232 path: "src/popular.rs".into(),
233 count: 8,
234 last_used: now,
235 },
236 );
237 drop(s);
238
239 let order = super::rerank_by_frecency(vec![
240 "README.md".to_string(),
241 "src/popular.rs".to_string(),
242 "Cargo.toml".to_string(),
243 ]);
244 assert_eq!(order[0], "src/popular.rs");
245 // README.md was first in original order; Cargo.toml second. Both score 0
246 // so the original relative order survives.
247 assert_eq!(order[1], "README.md");
248 assert_eq!(order[2], "Cargo.toml");
249 }
250
251 /// Decayed score drops below a freshly-used entry after enough half-lives
252 /// that count alone can't carry the older one. With a 7-day half-life,
253 /// 8 weeks gives 8 half-lives → ~256× decay; an entry mentioned twice
254 /// today comfortably beats one mentioned 50× two months ago.
255 #[test]
256 fn old_entries_decay_below_recent_ones() {
257 let now: u64 = 7 * 24 * 60 * 60 * 8; // 8 weeks (8 half-lives)
258 let stale = FrecencyRecord {
259 path: "x".into(),
260 count: 50,
261 last_used: 0,
262 };
263 let fresh = FrecencyRecord {
264 path: "y".into(),
265 count: 2,
266 last_used: now,
267 };
268 assert!(
269 super::decayed_score(&fresh, now) > super::decayed_score(&stale, now),
270 "fresh={}, stale={}",
271 super::decayed_score(&fresh, now),
272 super::decayed_score(&stale, now)
273 );
274 }
275 }
276
276 lines RUST