Skip to main content

noether_engine/
composition_cache.rs

1use crate::lagrange::CompositionGraph;
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7/// A cached composition entry.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct CachedComposition {
10    /// The original problem description (for display/debugging).
11    pub problem: String,
12    /// The resolved composition graph.
13    pub graph: CompositionGraph,
14    /// Unix timestamp of when this was cached.
15    pub cached_at: u64,
16    /// Which model produced this graph.
17    pub model: String,
18}
19
20/// Persistent cache that maps normalized problem hashes to composition graphs.
21///
22/// Stored as JSON at `~/.noether/compositions.json` (or `NOETHER_HOME/compositions.json`).
23/// `--force` bypasses the cache and always re-runs the LLM.
24pub struct CompositionCache {
25    path: PathBuf,
26    entries: HashMap<String, CachedComposition>,
27}
28
29impl CompositionCache {
30    /// Open or create the cache at `path`.
31    pub fn open(path: impl AsRef<Path>) -> Self {
32        let path = path.as_ref().to_path_buf();
33        let entries = if path.exists() {
34            std::fs::read_to_string(&path)
35                .ok()
36                .and_then(|s| serde_json::from_str(&s).ok())
37                .unwrap_or_default()
38        } else {
39            HashMap::new()
40        };
41        Self { path, entries }
42    }
43
44    /// Look up a problem. Returns `None` if not cached or cache is empty.
45    pub fn get(&self, problem: &str) -> Option<&CachedComposition> {
46        let key = normalize_key(problem);
47        self.entries.get(&key)
48    }
49
50    /// Store a new composition result.
51    pub fn insert(&mut self, problem: &str, graph: CompositionGraph, model: &str) {
52        let key = normalize_key(problem);
53        let now = std::time::SystemTime::now()
54            .duration_since(std::time::UNIX_EPOCH)
55            .map(|d| d.as_secs())
56            .unwrap_or(0);
57        self.entries.insert(
58            key,
59            CachedComposition {
60                problem: problem.to_string(),
61                graph,
62                cached_at: now,
63                model: model.to_string(),
64            },
65        );
66        // Best-effort persist; failures are not fatal.
67        let _ = self.save();
68    }
69
70    /// Number of cached compositions.
71    pub fn len(&self) -> usize {
72        self.entries.len()
73    }
74
75    pub fn is_empty(&self) -> bool {
76        self.entries.is_empty()
77    }
78
79    fn save(&self) -> std::io::Result<()> {
80        if let Some(parent) = self.path.parent() {
81            std::fs::create_dir_all(parent)?;
82        }
83        let json = serde_json::to_string_pretty(&self.entries).map_err(std::io::Error::other)?;
84        std::fs::write(&self.path, json)
85    }
86}
87
88/// SHA-256 of the lower-cased, whitespace-normalized problem text.
89fn normalize_key(problem: &str) -> String {
90    let normalized = problem
91        .split_whitespace()
92        .collect::<Vec<_>>()
93        .join(" ")
94        .to_lowercase();
95    hex::encode(Sha256::digest(normalized.as_bytes()))
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::lagrange::{parse_graph, CompositionGraph};
102    use tempfile::NamedTempFile;
103
104    fn dummy_graph() -> CompositionGraph {
105        parse_graph(r#"{"description":"test","version":"0.1.0","root":{"op":"Stage","id":"abc"}}"#)
106            .unwrap()
107    }
108
109    #[test]
110    fn cache_roundtrip() {
111        let tmp = NamedTempFile::new().unwrap();
112        let mut cache = CompositionCache::open(tmp.path());
113        assert!(cache.get("hello world").is_none());
114
115        cache.insert("hello world", dummy_graph(), "test-model");
116        let hit = cache.get("hello world").unwrap();
117        assert_eq!(hit.problem, "hello world");
118        assert_eq!(hit.model, "test-model");
119    }
120
121    #[test]
122    fn cache_key_normalizes_whitespace_and_case() {
123        let tmp = NamedTempFile::new().unwrap();
124        let mut cache = CompositionCache::open(tmp.path());
125        cache.insert("hello  WORLD", dummy_graph(), "m");
126
127        // Different whitespace / case → same key
128        assert!(cache.get("hello world").is_some());
129        assert!(cache.get("HELLO WORLD").is_some());
130        assert!(cache.get("  hello   world  ").is_some());
131    }
132
133    #[test]
134    fn cache_persists_across_reopen() {
135        let tmp = NamedTempFile::new().unwrap();
136        {
137            let mut cache = CompositionCache::open(tmp.path());
138            cache.insert("persist me", dummy_graph(), "m");
139        }
140        let cache2 = CompositionCache::open(tmp.path());
141        assert!(cache2.get("persist me").is_some());
142    }
143}