noether_engine/
composition_cache.rs1use crate::lagrange::CompositionGraph;
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct CachedComposition {
10 pub problem: String,
12 pub graph: CompositionGraph,
14 pub cached_at: u64,
16 pub model: String,
18}
19
20pub struct CompositionCache {
25 path: PathBuf,
26 entries: HashMap<String, CachedComposition>,
27}
28
29impl CompositionCache {
30 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 pub fn get(&self, problem: &str) -> Option<&CachedComposition> {
46 let key = normalize_key(problem);
47 self.entries.get(&key)
48 }
49
50 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 let _ = self.save();
68 }
69
70 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
88fn 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 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}