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