Skip to main content

spool/
reference_tracker.rs

1//! Reference tracker — lightweight side-channel that records when each
2//! lifecycle memory record was last retrieved (referenced). Lives
3//! outside the append-only ledger as a mutable JSON file at
4//! `<lifecycle_root>/reference-tracker.json`.
5//!
6//! Used by the staleness detection subsystem (Phase 4 Round 17) to
7//! apply a scoring penalty to memories that haven't been retrieved in
8//! a long time.
9//!
10//! ## Concurrency
11//! Uses `fs2::FileExt::lock_exclusive` (POSIX flock) to serialize
12//! mutations. Same model as `distill_queue.rs`.
13
14use fs2::FileExt;
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17use std::fs::{self, File, OpenOptions};
18use std::io::Read as _;
19use std::path::{Path, PathBuf};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22pub const TRACKER_FILE_NAME: &str = "reference-tracker.json";
23const SCHEMA_VERSION: &str = "reference-tracker.v1";
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct ReferenceMap {
27    #[serde(default)]
28    pub schema_version: String,
29    #[serde(default)]
30    pub records: BTreeMap<String, ReferenceEntry>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ReferenceEntry {
35    /// ISO 8601 UTC timestamp, e.g. "2026-05-08T14:30:00Z"
36    pub last_referenced_at: String,
37    pub count: u64,
38}
39
40/// Resolve the tracker file path under `<root>/reference-tracker.json`.
41pub fn tracker_path(root: &Path) -> PathBuf {
42    root.join(TRACKER_FILE_NAME)
43}
44
45/// Load tracker, update `last_referenced_at` to now (UTC ISO 8601) and
46/// increment `count` for each record_id. Creates file if absent. Uses
47/// file locking for concurrent safety. Errors are swallowed (eprintln
48/// + return) — retrieval must never fail because of tracker I/O.
49pub fn touch(root: &Path, record_ids: &[&str]) {
50    if record_ids.is_empty() {
51        return;
52    }
53    if let Err(err) = touch_inner(root, record_ids) {
54        eprintln!("[spool] reference tracker touch failed: {err}");
55    }
56}
57
58/// Load and parse the tracker file. Returns empty map if file missing
59/// or corrupt.
60pub fn read(root: &Path) -> ReferenceMap {
61    let path = tracker_path(root);
62    if !path.exists() {
63        return ReferenceMap::default();
64    }
65    match fs::read_to_string(&path) {
66        Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
67        Err(_) => ReferenceMap::default(),
68    }
69}
70
71/// Parse `last_referenced_at` and return days elapsed since then.
72/// Returns None if parse fails.
73pub fn age_days(entry: &ReferenceEntry) -> Option<u64> {
74    let referenced_secs = parse_iso8601_to_unix_secs(&entry.last_referenced_at)?;
75    let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
76    if now_secs < referenced_secs {
77        return Some(0);
78    }
79    Some((now_secs - referenced_secs) / 86400)
80}
81
82/// Apply the staleness decay curve:
83/// - 0-14 days: 0
84/// - 15-30 days: -2
85/// - 31-60 days: -4
86/// - 61-90 days: -6
87/// - 91+ days: -8
88/// - None (never referenced): 0
89pub fn staleness_penalty(age: Option<u64>) -> i32 {
90    match age {
91        None => 0,
92        Some(days) => match days {
93            0..=3 => 4,
94            4..=7 => 2,
95            8..=14 => 0,
96            15..=30 => -2,
97            31..=60 => -4,
98            61..=90 => -6,
99            _ => -8,
100        },
101    }
102}
103
104// --- Internal helpers ---
105
106fn touch_inner(root: &Path, record_ids: &[&str]) -> anyhow::Result<()> {
107    fs::create_dir_all(root)
108        .map_err(|e| anyhow::anyhow!("creating tracker dir {}: {e}", root.display()))?;
109
110    let path = tracker_path(root);
111    let file = OpenOptions::new()
112        .create(true)
113        .truncate(false)
114        .read(true)
115        .write(true)
116        .open(&path)
117        .map_err(|e| anyhow::anyhow!("opening tracker {}: {e}", path.display()))?;
118    file.lock_exclusive()
119        .map_err(|e| anyhow::anyhow!("locking tracker {}: {e}", path.display()))?;
120
121    let result = (|| -> anyhow::Result<()> {
122        let mut content = String::new();
123        // Re-read under lock to avoid TOCTOU.
124        let mut reader =
125            File::open(&path).map_err(|e| anyhow::anyhow!("re-reading tracker: {e}"))?;
126        reader.read_to_string(&mut content).ok();
127
128        let mut map: ReferenceMap = if content.trim().is_empty() {
129            ReferenceMap::default()
130        } else {
131            serde_json::from_str(&content).unwrap_or_default()
132        };
133        map.schema_version = SCHEMA_VERSION.to_string();
134
135        let now = now_iso8601();
136        for &id in record_ids {
137            let entry = map.records.entry(id.to_string()).or_insert(ReferenceEntry {
138                last_referenced_at: now.clone(),
139                count: 0,
140            });
141            entry.last_referenced_at = now.clone();
142            entry.count += 1;
143        }
144
145        let serialized = serde_json::to_string_pretty(&map)
146            .map_err(|e| anyhow::anyhow!("serializing tracker: {e}"))?;
147        fs::write(&path, serialized)
148            .map_err(|e| anyhow::anyhow!("writing tracker {}: {e}", path.display()))?;
149        Ok(())
150    })();
151
152    let _ = FileExt::unlock(&file);
153    result
154}
155
156/// Generate current UTC time as ISO 8601 string (e.g. "2026-05-08T14:30:00Z").
157/// Uses std::time only — no chrono dependency.
158fn now_iso8601() -> String {
159    let secs = SystemTime::now()
160        .duration_since(UNIX_EPOCH)
161        .unwrap_or_default()
162        .as_secs();
163    unix_secs_to_iso8601(secs)
164}
165
166/// Convert unix seconds to ISO 8601 UTC string.
167fn unix_secs_to_iso8601(secs: u64) -> String {
168    // Manual conversion from unix timestamp to calendar date/time.
169    let days = secs / 86400;
170    let time_of_day = secs % 86400;
171    let hours = time_of_day / 3600;
172    let minutes = (time_of_day % 3600) / 60;
173    let seconds = time_of_day % 60;
174
175    let (year, month, day) = days_to_ymd(days);
176    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
177}
178
179/// Convert days since Unix epoch (1970-01-01) to (year, month, day).
180fn days_to_ymd(days: u64) -> (u64, u64, u64) {
181    // Algorithm adapted from Howard Hinnant's civil_from_days.
182    let z = days + 719468;
183    let era = z / 146097;
184    let doe = z - era * 146097;
185    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
186    let y = yoe + era * 400;
187    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
188    let mp = (5 * doy + 2) / 153;
189    let d = doy - (153 * mp + 2) / 5 + 1;
190    let m = if mp < 10 { mp + 3 } else { mp - 9 };
191    let y = if m <= 2 { y + 1 } else { y };
192    (y, m, d)
193}
194
195/// Parse a subset of ISO 8601 timestamps to unix seconds.
196/// Supports: "YYYY-MM-DDTHH:MM:SSZ" and "YYYY-MM-DDTHH:MM:SS+00:00".
197fn parse_iso8601_to_unix_secs(s: &str) -> Option<u64> {
198    let s = s.trim();
199    // Minimum length: "2026-05-08T14:30:00Z" = 20 chars
200    if s.len() < 20 {
201        return None;
202    }
203    let year: u64 = s.get(0..4)?.parse().ok()?;
204    if s.as_bytes().get(4)? != &b'-' {
205        return None;
206    }
207    let month: u64 = s.get(5..7)?.parse().ok()?;
208    if s.as_bytes().get(7)? != &b'-' {
209        return None;
210    }
211    let day: u64 = s.get(8..10)?.parse().ok()?;
212    if s.as_bytes().get(10)? != &b'T' {
213        return None;
214    }
215    let hour: u64 = s.get(11..13)?.parse().ok()?;
216    if s.as_bytes().get(13)? != &b':' {
217        return None;
218    }
219    let min: u64 = s.get(14..16)?.parse().ok()?;
220    if s.as_bytes().get(16)? != &b':' {
221        return None;
222    }
223    let sec: u64 = s.get(17..19)?.parse().ok()?;
224
225    // Validate ranges
226    if !(1..=12).contains(&month) || !(1..=31).contains(&day) || hour > 23 || min > 59 || sec > 59 {
227        return None;
228    }
229
230    // Check timezone suffix: must be 'Z' or '+00:00' or '-00:00'
231    let tz_part = s.get(19..)?;
232    if tz_part != "Z" && tz_part != "+00:00" && tz_part != "-00:00" {
233        return None;
234    }
235
236    let days = ymd_to_days(year, month, day)?;
237    Some(days * 86400 + hour * 3600 + min * 60 + sec)
238}
239
240/// Convert (year, month, day) to days since Unix epoch.
241/// Returns None for invalid dates.
242fn ymd_to_days(year: u64, month: u64, day: u64) -> Option<u64> {
243    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
244        return None;
245    }
246    // Howard Hinnant's days_from_civil (adjusted for unsigned).
247    let y = if month <= 2 { year - 1 } else { year };
248    let m = if month <= 2 { month + 9 } else { month - 3 };
249    let era = y / 400;
250    let yoe = y - era * 400;
251    let doy = (153 * m + 2) / 5 + day - 1;
252    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
253    let days = era * 146097 + doe;
254    // Subtract the epoch offset (days from 0000-03-01 to 1970-01-01).
255    days.checked_sub(719468)
256}
257
258#[cfg(test)]
259pub mod tests {
260    use super::*;
261    use tempfile::tempdir;
262
263    /// Test helper: convert unix seconds to ISO 8601 string.
264    /// Exposed so other test modules (e.g. scorer tests) can build
265    /// synthetic `ReferenceEntry` values with known ages.
266    pub fn unix_secs_to_iso8601_for_test(secs: u64) -> String {
267        super::unix_secs_to_iso8601(secs)
268    }
269
270    #[test]
271    fn touch_creates_file_when_absent() {
272        let temp = tempdir().unwrap();
273        let root = temp.path();
274        assert!(!tracker_path(root).exists());
275
276        touch(root, &["rec-001", "rec-002"]);
277
278        assert!(tracker_path(root).exists());
279        let map = read(root);
280        assert_eq!(map.schema_version, SCHEMA_VERSION);
281        assert_eq!(map.records.len(), 2);
282        assert!(map.records.contains_key("rec-001"));
283        assert!(map.records.contains_key("rec-002"));
284    }
285
286    #[test]
287    fn touch_updates_existing_entry() {
288        let temp = tempdir().unwrap();
289        let root = temp.path();
290
291        // Seed with an old timestamp.
292        let mut map = ReferenceMap {
293            schema_version: SCHEMA_VERSION.to_string(),
294            records: BTreeMap::new(),
295        };
296        map.records.insert(
297            "rec-001".to_string(),
298            ReferenceEntry {
299                last_referenced_at: "2020-01-01T00:00:00Z".to_string(),
300                count: 5,
301            },
302        );
303        fs::create_dir_all(root).unwrap();
304        fs::write(
305            tracker_path(root),
306            serde_json::to_string_pretty(&map).unwrap(),
307        )
308        .unwrap();
309
310        touch(root, &["rec-001"]);
311
312        let updated = read(root);
313        let entry = updated.records.get("rec-001").unwrap();
314        // Timestamp should be updated (not the old one).
315        assert_ne!(entry.last_referenced_at, "2020-01-01T00:00:00Z");
316        assert!(entry.last_referenced_at.ends_with('Z'));
317    }
318
319    #[test]
320    fn touch_increments_count() {
321        let temp = tempdir().unwrap();
322        let root = temp.path();
323
324        touch(root, &["rec-001"]);
325        let map = read(root);
326        assert_eq!(map.records["rec-001"].count, 1);
327
328        touch(root, &["rec-001"]);
329        let map = read(root);
330        assert_eq!(map.records["rec-001"].count, 2);
331
332        touch(root, &["rec-001", "rec-001"]);
333        let map = read(root);
334        // Two touches in one call = +2.
335        assert_eq!(map.records["rec-001"].count, 4);
336    }
337
338    #[test]
339    fn read_returns_empty_for_missing_file() {
340        let temp = tempdir().unwrap();
341        let map = read(temp.path());
342        assert!(map.records.is_empty());
343        assert_eq!(map.schema_version, "");
344    }
345
346    #[test]
347    fn read_returns_empty_for_corrupt_file() {
348        let temp = tempdir().unwrap();
349        let root = temp.path();
350        fs::write(tracker_path(root), "not valid json {{{").unwrap();
351        let map = read(root);
352        assert!(map.records.is_empty());
353    }
354
355    #[test]
356    fn age_days_computes_correctly() {
357        // Use a timestamp that is exactly 30 days ago.
358        let now_secs = SystemTime::now()
359            .duration_since(UNIX_EPOCH)
360            .unwrap()
361            .as_secs();
362        let thirty_days_ago = now_secs - 30 * 86400;
363        let ts = unix_secs_to_iso8601(thirty_days_ago);
364        let entry = ReferenceEntry {
365            last_referenced_at: ts,
366            count: 1,
367        };
368        let days = age_days(&entry).unwrap();
369        assert_eq!(days, 30);
370    }
371
372    #[test]
373    fn age_days_returns_none_for_invalid_timestamp() {
374        let entry = ReferenceEntry {
375            last_referenced_at: "not-a-timestamp".to_string(),
376            count: 1,
377        };
378        assert_eq!(age_days(&entry), None);
379
380        let entry2 = ReferenceEntry {
381            last_referenced_at: "2026-13-01T00:00:00Z".to_string(), // invalid month
382            count: 1,
383        };
384        assert_eq!(age_days(&entry2), None);
385    }
386
387    #[test]
388    fn staleness_penalty_curve() {
389        assert_eq!(staleness_penalty(None), 0);
390        assert_eq!(staleness_penalty(Some(0)), 4);
391        assert_eq!(staleness_penalty(Some(3)), 4);
392        assert_eq!(staleness_penalty(Some(4)), 2);
393        assert_eq!(staleness_penalty(Some(7)), 2);
394        assert_eq!(staleness_penalty(Some(8)), 0);
395        assert_eq!(staleness_penalty(Some(14)), 0);
396        assert_eq!(staleness_penalty(Some(15)), -2);
397        assert_eq!(staleness_penalty(Some(30)), -2);
398        assert_eq!(staleness_penalty(Some(31)), -4);
399        assert_eq!(staleness_penalty(Some(60)), -4);
400        assert_eq!(staleness_penalty(Some(61)), -6);
401        assert_eq!(staleness_penalty(Some(90)), -6);
402        assert_eq!(staleness_penalty(Some(91)), -8);
403        assert_eq!(staleness_penalty(Some(365)), -8);
404    }
405
406    #[test]
407    fn iso8601_roundtrip() {
408        // Verify our formatting and parsing are consistent.
409        let secs: u64 = 1_715_000_000; // ~2024-05-06
410        let formatted = unix_secs_to_iso8601(secs);
411        let parsed = parse_iso8601_to_unix_secs(&formatted).unwrap();
412        assert_eq!(parsed, secs);
413    }
414}