microscope_memory/
salience.rs1use std::fs;
17use std::path::Path;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20const SALIENCE_THRESHOLD: f32 = 0.3;
24const INHIBITION_STRENGTH: f32 = 0.5;
27const INHIBITION_HALFLIFE_MS: u64 = 300_000;
29const MAX_INHIBITIONS: usize = 50;
31
32#[derive(Clone, Debug)]
36pub struct InhibitionEntry {
37 pub topic_hash: u64,
38 pub remaining_strength: f32,
39 pub created_ms: u64,
40}
41
42pub struct SalienceState {
46 pub inhibitions: Vec<InhibitionEntry>,
47}
48
49impl SalienceState {
50 pub fn load_or_init(output_dir: &Path) -> Self {
52 let path = output_dir.join("salience.bin");
53 if let Ok(data) = fs::read(&path) {
54 if data.len() >= 8 && &data[0..4] == b"SAL1" {
55 let count = u32::from_le_bytes(data[4..8].try_into().unwrap()) as usize;
56 let mut inhibitions = Vec::with_capacity(count);
57 let mut off = 8;
58 for _ in 0..count {
59 if off + 20 > data.len() {
60 break;
61 }
62 let topic_hash = u64::from_le_bytes(data[off..off + 8].try_into().unwrap());
63 let remaining = f32::from_le_bytes(data[off + 8..off + 12].try_into().unwrap());
64 let created = u64::from_le_bytes(data[off + 12..off + 20].try_into().unwrap());
65 inhibitions.push(InhibitionEntry {
66 topic_hash,
67 remaining_strength: remaining,
68 created_ms: created,
69 });
70 off += 20;
71 }
72 return SalienceState { inhibitions };
73 }
74 }
75 SalienceState {
76 inhibitions: Vec::new(),
77 }
78 }
79
80 pub fn save(&self, output_dir: &Path) -> Result<(), String> {
82 let path = output_dir.join("salience.bin");
83 let mut buf = Vec::with_capacity(8 + self.inhibitions.len() * 20);
84 buf.extend_from_slice(b"SAL1");
85 buf.extend_from_slice(&(self.inhibitions.len() as u32).to_le_bytes());
86 for e in &self.inhibitions {
87 buf.extend_from_slice(&e.topic_hash.to_le_bytes());
88 buf.extend_from_slice(&e.remaining_strength.to_le_bytes());
89 buf.extend_from_slice(&e.created_ms.to_le_bytes());
90 }
91 let tmp_path = output_dir.join("salience.bin.tmp");
92 fs::write(&tmp_path, &buf).map_err(|e| format!("write salience.bin: {}", e))?;
93 fs::rename(&tmp_path, &path).map_err(|e| format!("rename salience.bin: {}", e))
94 }
95
96 pub fn decay(&mut self) {
98 let now = now_ms();
99 let half_life = INHIBITION_HALFLIFE_MS as f32;
100 self.inhibitions.retain(|e| {
101 let age = now.saturating_sub(e.created_ms) as f32;
102 let decay = (-age / half_life).exp();
103 let remaining = e.remaining_strength * decay;
104 remaining > 0.01 });
106 }
107
108 pub fn inhibit(&mut self, topic_hash: u64) {
110 if let Some(existing) = self
112 .inhibitions
113 .iter_mut()
114 .find(|e| e.topic_hash == topic_hash)
115 {
116 existing.remaining_strength =
117 (existing.remaining_strength + INHIBITION_STRENGTH).min(1.0);
118 existing.created_ms = now_ms();
119 return;
120 }
121 if self.inhibitions.len() >= MAX_INHIBITIONS {
123 self.inhibitions.remove(0); }
125 self.inhibitions.push(InhibitionEntry {
126 topic_hash,
127 remaining_strength: INHIBITION_STRENGTH,
128 created_ms: now_ms(),
129 });
130 }
131
132 pub fn compute_salience(
136 &self,
137 emotional_delta: f32,
138 insight_score: f32,
139 recency_factor: f32,
140 topic_hash: u64,
141 ) -> f32 {
142 let base = emotional_delta * insight_score * recency_factor;
143 let inhibition = self
145 .inhibitions
146 .iter()
147 .find(|e| e.topic_hash == topic_hash)
148 .map(|e| e.remaining_strength)
149 .unwrap_or(0.0);
150 let salience = base * (1.0 - inhibition);
151 if salience < SALIENCE_THRESHOLD {
152 0.0
153 } else {
154 salience
155 }
156 }
157
158 pub fn topic_hash(text: &str) -> u64 {
160 let bytes = text.as_bytes();
161 let mut h = 0x9e3779b97f4a7c15u64;
162 for chunk in bytes.chunks(8) {
163 let mut word = 0u64;
164 for (i, &b) in chunk.iter().enumerate() {
165 word |= (b as u64) << (i * 8);
166 }
167 h = h.wrapping_mul(0x517cc1b727220a95).wrapping_add(word);
168 }
169 h
170 }
171
172 pub fn filter(
175 &mut self,
176 scores: &[(u32, f32, f32, f32)], ) -> Vec<(u32, f32)> {
178 self.decay();
179 scores
180 .iter()
181 .filter_map(|&(idx, ed, isc, rec)| {
182 let topic_h = SalienceState::topic_hash(&format!("block_{}", idx));
183 let s = self.compute_salience(ed, isc, rec, topic_h);
184 if s > SALIENCE_THRESHOLD {
185 Some((idx, s))
186 } else {
187 None
188 }
189 })
190 .collect()
191 }
192}
193
194fn now_ms() -> u64 {
195 SystemTime::now()
196 .duration_since(UNIX_EPOCH)
197 .unwrap_or_default()
198 .as_millis() as u64
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn test_empty_state() {
207 let s = SalienceState::load_or_init(Path::new("."));
208 assert!(s.inhibitions.is_empty());
209 }
210
211 #[test]
212 fn test_inhibit_and_decay() {
213 let mut s = SalienceState::load_or_init(Path::new("."));
214 s.inhibit(42);
215 assert_eq!(s.inhibitions.len(), 1);
216 assert!((s.inhibitions[0].remaining_strength - 0.5).abs() < 0.01);
217 s.decay();
218 let score = s.compute_salience(1.0, 0.8, 1.0, 42);
219 assert!(score < 0.5); }
221
222 #[test]
223 fn test_save_load_roundtrip() {
224 let dir = std::env::temp_dir();
225 let mut s = SalienceState::load_or_init(&dir);
226 s.inhibit(100);
227 s.inhibit(200);
228 s.save(&dir).unwrap();
229
230 let loaded = SalienceState::load_or_init(&dir);
231 assert_eq!(loaded.inhibitions.len(), 2);
232
233 let _ = std::fs::remove_file(dir.join("salience.bin"));
234 }
235
236 #[test]
237 fn test_topic_hash_deterministic() {
238 let h1 = SalienceState::topic_hash("hello world");
239 let h2 = SalienceState::topic_hash("hello world");
240 assert_eq!(h1, h2);
241 }
242
243 #[test]
244 fn test_filter_below_threshold() {
245 let mut s = SalienceState::load_or_init(Path::new("."));
246 let scores = vec![(0u32, 0.01f32, 0.01, 0.01)]; let filtered = s.filter(&scores);
248 assert!(filtered.is_empty());
249 }
250
251 #[test]
252 fn test_inhibition_reduces_salience() {
253 let mut s = SalienceState::load_or_init(Path::new("."));
254 let topic = SalienceState::topic_hash("test_topic");
255
256 let before = s.compute_salience(1.0, 0.8, 1.0, topic);
258
259 s.inhibit(topic);
261 let after = s.compute_salience(1.0, 0.8, 1.0, topic);
262
263 assert!(after < before);
264 }
265}