Skip to main content

aegis_memory/
taxonomy.rs

1use std::collections::HashMap;
2
3/// Wing/Room/Drawer memory taxonomy (mempalace hierarchy).
4/// `halls` are content-type tags on a wing; `closets` are a per-room
5/// secondary index; `tunnels` are cross-wing associations.
6/// Re-export from entry module for use in Drawer.
7pub use crate::entry::Reinforcement;
8
9/// A verbatim chunk of content stored in the palace.
10/// Enhanced with confidence scoring, graph links, and supersession tracking.
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
12pub struct Drawer {
13    pub id: String,        // drawer_{wing}_{room}_{hash}
14    pub content: String,   // verbatim chunk (800 chars, 100 overlap)
15    pub wing: String,      // top-level domain
16    pub room: String,      // topic
17    pub source_file: String,
18    pub chunk_index: u32,
19    pub filed_at: chrono::DateTime<chrono::Utc>,
20    // ── v2 additions: confidence + graph ──
21    /// Base confidence (0.0 - 1.0). Defaults to 0.7.
22    #[serde(default = "default_confidence")]
23    pub confidence: f32,
24    /// Number of times this drawer was accessed/retrieved.
25    #[serde(default)]
26    pub access_count: u32,
27    /// Last time this drawer was accessed.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub accessed_at: Option<chrono::DateTime<chrono::Utc>>,
30    /// Linked drawer IDs (graph edges).
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub linked_ids: Vec<String>,
33    /// If superseded by a newer drawer, this points to it.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub superseded_by: Option<String>,
36    /// Reinforcement history.
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub reinforcements: Vec<Reinforcement>,
39    /// Whether this drawer is still active (not superseded).
40    #[serde(default = "default_active")]
41    pub active: bool,
42}
43
44fn default_confidence() -> f32 { 0.7 }
45fn default_active() -> bool { true }
46
47impl Drawer {
48    /// Create a new drawer with default v2 fields.
49    pub fn new(
50        id: String,
51        content: String,
52        wing: String,
53        room: String,
54        source_file: String,
55        chunk_index: u32,
56    ) -> Self {
57        Self {
58            id,
59            content,
60            wing,
61            room,
62            source_file,
63            chunk_index,
64            filed_at: chrono::Utc::now(),
65            confidence: 0.7,
66            access_count: 0,
67            accessed_at: None,
68            linked_ids: Vec::new(),
69            superseded_by: None,
70            reinforcements: Vec::new(),
71            active: true,
72        }
73    }
74
75    /// Compute effective confidence based on access history, age, and reinforcements.
76    ///
77    /// Formula:
78    ///   effective = base_confidence
79    ///             + access_boost (ln(access_count) * 0.05)
80    ///             - age_decay (min(age_days/365, 0.3))
81    ///             + reinforcement_adjustment
82    ///   clamped to [0.0, 1.0]
83    pub fn effective_confidence(&self) -> f32 {
84        let base = self.confidence;
85
86        // Access boost: logarithmic, diminishing returns
87        let access_boost = if self.access_count > 1 {
88            (self.access_count as f32).ln() * 0.05
89        } else {
90            0.0
91        };
92
93        // Age decay: linear up to 30% over a year
94        let age_days = self.accessed_at
95            .or(Some(self.filed_at))
96            .map(|t| chrono::Utc::now().signed_duration_since(t).num_days().max(0) as f32)
97            .unwrap_or(0.0);
98        let age_decay = (age_days / 365.0).min(0.3);
99
100        // Reinforcement adjustment: average of recent scores (last 10)
101        let reinforcement_adj = if self.reinforcements.is_empty() {
102            0.0
103        } else {
104            let recent: Vec<f32> = self.reinforcements.iter()
105                .rev()
106                .take(10)
107                .map(|r| r.score)
108                .collect();
109            let avg: f32 = recent.iter().sum::<f32>() / recent.len() as f32;
110            (avg * 0.2).clamp(-0.15, 0.15)
111        };
112
113        (base + access_boost - age_decay + reinforcement_adj).clamp(0.0, 1.0)
114    }
115
116    /// Record an access event.
117    pub fn touch(&mut self) {
118        self.access_count += 1;
119        self.accessed_at = Some(chrono::Utc::now());
120    }
121
122    /// Record a reinforcement event (positive or negative).
123    pub fn reinforce(&mut self, score: f32, context: impl Into<String>) {
124        self.reinforcements.push(Reinforcement {
125            timestamp: chrono::Utc::now(),
126            score: score.clamp(-1.0, 1.0),
127            context: context.into(),
128            related_to: None,
129        });
130    }
131
132    /// Decrement base confidence.
133    pub fn decay_confidence(&mut self, amount: f32) {
134        self.confidence = (self.confidence - amount).max(0.0);
135    }
136
137    /// Mark this drawer as superseded by another.
138    pub fn supersede(&mut self, new_id: &str) {
139        self.superseded_by = Some(new_id.to_string());
140        self.active = false;
141    }
142
143    /// Add a bidirectional link to another drawer.
144    pub fn link_to(&mut self, other_id: &str) {
145        if !self.linked_ids.iter().any(|id| id == other_id) {
146            self.linked_ids.push(other_id.to_string());
147        }
148    }
149}
150
151/// Closet: compact secondary index — topic → drawer pointers
152#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
153pub struct Closet {
154    pub topic: String,
155    pub entities: Vec<String>,
156    pub drawer_ids: Vec<String>,
157}
158
159/// Explicit cross-wing association
160#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
161pub struct Tunnel {
162    pub source_wing: String,
163    pub source_room: String,
164    pub target_wing: String,
165    pub target_room: String,
166    pub relation: String, // "implements", "references", "contradicts"
167}
168
169impl Tunnel {
170    /// Return the source scope as `wing/room`.
171    pub fn source_scope(&self) -> String {
172        format!("{}/{}", self.source_wing, self.source_room)
173    }
174
175    /// Return the target scope as `wing/room`.
176    pub fn target_scope(&self) -> String {
177        format!("{}/{}", self.target_wing, self.target_room)
178    }
179}
180
181#[derive(Debug, Default)]
182pub struct Room {
183    pub drawers: Vec<Drawer>,
184    pub closets: Vec<Closet>,
185}
186
187#[derive(Debug, Default)]
188pub struct Wing {
189    pub rooms: HashMap<String, Room>,
190    pub halls: Vec<String>, // content type tags
191}
192
193/// Full memory palace
194#[derive(Debug, Default)]
195pub struct MemoryTaxonomy {
196    pub wings: HashMap<String, Wing>,
197    pub tunnels: Vec<Tunnel>,
198}
199
200impl MemoryTaxonomy {
201    /// Create an empty memory taxonomy.
202    pub fn new() -> Self {
203        Self::default()
204    }
205
206    /// Store verbatim text: chunk into 800-char pieces with 100-char overlap.
207    pub fn ingest(&mut self, wing: &str, room: &str, source_file: &str, text: &str) {
208        const CHUNK_SIZE: usize = 800;
209        const OVERLAP: usize = 100;
210
211        let wing_entry = self.wings.entry(wing.to_string()).or_default();
212        let room_entry = wing_entry.rooms.entry(room.to_string()).or_default();
213
214        let chars: Vec<char> = text.chars().collect();
215        let mut start = 0usize;
216        let mut chunk_index = 0u32;
217
218        while start < chars.len() {
219            let end = (start + CHUNK_SIZE).min(chars.len());
220            let chunk: String = chars[start..end].iter().collect();
221            let hash = crate::cas::ContentAddressedStorage::hash(&chunk);
222            let id = format!("drawer_{}_{}_{}", wing, room, &hash[..8]);
223
224            room_entry.drawers.push(Drawer::new(
225                id, chunk, wing.to_string(), room.to_string(),
226                source_file.to_string(), chunk_index,
227            ));
228
229            if end == chars.len() {
230                break;
231            }
232            start = end.saturating_sub(OVERLAP);
233            chunk_index += 1;
234        }
235    }
236
237    /// Add a closet (secondary index entry).
238    pub fn add_closet(&mut self, wing: &str, room: &str, closet: Closet) {
239        let wing_entry = self.wings.entry(wing.to_string()).or_default();
240        let room_entry = wing_entry.rooms.entry(room.to_string()).or_default();
241        room_entry.closets.push(closet);
242    }
243
244    /// Add a tunnel (cross-wing relation).
245    pub fn add_tunnel(&mut self, tunnel: Tunnel) {
246        self.tunnels.push(tunnel);
247    }
248
249    /// Get all drawers in a scope (wing/room).
250    pub fn drawers_in_scope(&self, wing: &str, room: &str) -> Vec<&Drawer> {
251        self.wings
252            .get(wing)
253            .and_then(|w| w.rooms.get(room))
254            .map(|r| r.drawers.iter().collect())
255            .unwrap_or_default()
256    }
257
258    /// Get tunnels originating from a scope.
259    pub fn tunnels_from(&self, wing: &str, room: &str) -> Vec<&Tunnel> {
260        self.tunnels
261            .iter()
262            .filter(|t| t.source_wing == wing && t.source_room == room)
263            .collect()
264    }
265
266    /// Find a drawer by ID across all wings/rooms.
267    pub fn find_drawer(&self, id: &str) -> Option<&Drawer> {
268        for wing in self.wings.values() {
269            for room in wing.rooms.values() {
270                if let Some(d) = room.drawers.iter().find(|d| d.id == id) {
271                    return Some(d);
272                }
273            }
274        }
275        None
276    }
277
278    /// Find a mutable drawer by ID across all wings/rooms.
279    pub fn find_drawer_mut(&mut self, id: &str) -> Option<&mut Drawer> {
280        for wing in self.wings.values_mut() {
281            for room in wing.rooms.values_mut() {
282                if let Some(d) = room.drawers.iter_mut().find(|d| d.id == id) {
283                    return Some(d);
284                }
285            }
286        }
287        None
288    }
289
290    /// Get all active (non-superseded) drawer IDs linked to a given drawer.
291    pub fn linked_active_ids(&self, drawer_id: &str) -> Vec<String> {
292        self.find_drawer(drawer_id)
293            .map(|d| {
294                d.linked_ids.iter()
295                    .filter(|lid| {
296                        self.find_drawer(lid)
297                            .map(|linked| linked.active)
298                            .unwrap_or(false)
299                    })
300                    .cloned()
301                    .collect()
302            })
303            .unwrap_or_default()
304    }
305
306    /// Count total drawers, active drawers, and superseded drawers.
307    pub fn stats(&self) -> (usize, usize, usize) {
308        let mut total = 0;
309        let mut active = 0;
310        let mut superseded = 0;
311        for wing in self.wings.values() {
312            for room in wing.rooms.values() {
313                for d in &room.drawers {
314                    total += 1;
315                    if d.active {
316                        active += 1;
317                    } else {
318                        superseded += 1;
319                    }
320                }
321            }
322        }
323        (total, active, superseded)
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn drawer_new_defaults() {
333        let d = Drawer::new("d1".into(), "hello".into(), "dev".into(), "rust".into(), "main.rs".into(), 0);
334        assert_eq!(d.id, "d1");
335        assert_eq!(d.content, "hello");
336        assert_eq!(d.confidence, 0.7);
337        assert!(d.active);
338        assert_eq!(d.access_count, 0);
339        assert!(d.linked_ids.is_empty());
340        assert!(d.superseded_by.is_none());
341        assert!(d.reinforcements.is_empty());
342    }
343
344    #[test]
345    fn drawer_effective_confidence_fresh() {
346        let d = Drawer::new("d1".into(), "c".into(), "w".into(), "r".into(), "f".into(), 0);
347        // Fresh drawer: ~0.7 (no age decay yet, no access boost)
348        let eff = d.effective_confidence();
349        assert!((0.65..=0.75).contains(&eff), "expected ~0.7, got {}", eff);
350    }
351
352    #[test]
353    fn drawer_effective_confidence_with_access() {
354        let mut d = Drawer::new("d1".into(), "c".into(), "w".into(), "r".into(), "f".into(), 0);
355        d.access_count = 10;
356        d.accessed_at = Some(chrono::Utc::now());
357        let eff = d.effective_confidence();
358        assert!(eff > 0.7, "access boost should raise confidence, got {}", eff);
359    }
360
361    #[test]
362    fn drawer_touch() {
363        let mut d = Drawer::new("d1".into(), "c".into(), "w".into(), "r".into(), "f".into(), 0);
364        d.touch();
365        assert_eq!(d.access_count, 1);
366        assert!(d.accessed_at.is_some());
367    }
368
369    #[test]
370    fn drawer_reinforce() {
371        let mut d = Drawer::new("d1".into(), "c".into(), "w".into(), "r".into(), "f".into(), 0);
372        d.reinforce(0.5, "positive feedback");
373        assert_eq!(d.reinforcements.len(), 1);
374        assert_eq!(d.reinforcements[0].score, 0.5);
375        // Score clamped
376        d.reinforce(2.0, "clamped");
377        assert_eq!(d.reinforcements[1].score, 1.0);
378    }
379
380    #[test]
381    fn drawer_decay_confidence() {
382        let mut d = Drawer::new("d1".into(), "c".into(), "w".into(), "r".into(), "f".into(), 0);
383        d.decay_confidence(0.3);
384        assert!((d.confidence - 0.4).abs() < 0.01);
385        d.decay_confidence(1.0);
386        assert_eq!(d.confidence, 0.0); // floored at 0
387    }
388
389    #[test]
390    fn drawer_supersede() {
391        let mut d = Drawer::new("d1".into(), "c".into(), "w".into(), "r".into(), "f".into(), 0);
392        d.supersede("d2");
393        assert_eq!(d.superseded_by, Some("d2".to_string()));
394        assert!(!d.active);
395    }
396
397    #[test]
398    fn drawer_link_to_no_duplicates() {
399        let mut d = Drawer::new("d1".into(), "c".into(), "w".into(), "r".into(), "f".into(), 0);
400        d.link_to("d2");
401        d.link_to("d2"); // duplicate
402        d.link_to("d3");
403        assert_eq!(d.linked_ids.len(), 2);
404    }
405
406    #[test]
407    fn drawer_effective_confidence_with_reinforcements() {
408        let mut d = Drawer::new("d1".into(), "c".into(), "w".into(), "r".into(), "f".into(), 0);
409        d.reinforce(0.5, "good");
410        d.reinforce(0.5, "good");
411        d.reinforce(0.5, "good");
412        let eff = d.effective_confidence();
413        assert!(eff > 0.7, "positive reinforcement should boost, got {}", eff);
414    }
415
416    #[test]
417    fn tunnel_scopes() {
418        let t = Tunnel {
419            source_wing: "dev".into(),
420            source_room: "rust".into(),
421            target_wing: "ops".into(),
422            target_room: "deploy".into(),
423            relation: "implements".into(),
424        };
425        assert_eq!(t.source_scope(), "dev/rust");
426        assert_eq!(t.target_scope(), "ops/deploy");
427    }
428
429    #[test]
430    fn memory_taxonomy_ingest_short_text() {
431        let mut tax = MemoryTaxonomy::new();
432        tax.ingest("dev", "rust", "main.rs", "Hello world");
433        assert_eq!(tax.wings.len(), 1);
434        assert_eq!(tax.drawers_in_scope("dev", "rust").len(), 1);
435    }
436
437    #[test]
438    fn memory_taxonomy_ingest_long_text_chunks() {
439        let mut tax = MemoryTaxonomy::new();
440        // Use varied content so chunks have different hashes
441        let long_text: String = (0..2000).map(|i| (b'a' + (i % 26) as u8) as char).collect();
442        tax.ingest("dev", "rust", "big.rs", &long_text);
443        let drawers = tax.drawers_in_scope("dev", "rust");
444        assert!(drawers.len() >= 2, "expected multiple chunks, got {}", drawers.len());
445        // All drawers should have unique IDs
446        let mut ids: Vec<&str> = drawers.iter().map(|d| d.id.as_str()).collect();
447        ids.sort();
448        ids.dedup();
449        assert_eq!(ids.len(), drawers.len());
450    }
451
452    #[test]
453    fn memory_taxonomy_add_tunnel() {
454        let mut tax = MemoryTaxonomy::new();
455        tax.add_tunnel(Tunnel {
456            source_wing: "a".into(), source_room: "b".into(),
457            target_wing: "c".into(), target_room: "d".into(),
458            relation: "references".into(),
459        });
460        assert_eq!(tax.tunnels.len(), 1);
461        assert_eq!(tax.tunnels_from("a", "b").len(), 1);
462        assert_eq!(tax.tunnels_from("c", "d").len(), 0); // wrong direction
463    }
464
465    #[test]
466    fn memory_taxonomy_find_drawer() {
467        let mut tax = MemoryTaxonomy::new();
468        tax.ingest("dev", "rust", "main.rs", "test content");
469        let drawer = &tax.drawers_in_scope("dev", "rust")[0];
470        let id = drawer.id.clone();
471        assert!(tax.find_drawer(&id).is_some());
472        assert!(tax.find_drawer("nonexistent").is_none());
473    }
474
475    #[test]
476    fn memory_taxonomy_find_drawer_mut_and_supersede() {
477        let mut tax = MemoryTaxonomy::new();
478        tax.ingest("dev", "rust", "main.rs", "content");
479        let id = tax.drawers_in_scope("dev", "rust")[0].id.clone();
480        tax.find_drawer_mut(&id).unwrap().supersede("new_id");
481        assert!(!tax.find_drawer(&id).unwrap().active);
482        let (total, active, superseded) = tax.stats();
483        assert_eq!(total, 1);
484        assert_eq!(active, 0);
485        assert_eq!(superseded, 1);
486    }
487
488    #[test]
489    fn memory_taxonomy_linked_active_ids() {
490        let mut tax = MemoryTaxonomy::new();
491        tax.ingest("dev", "rust", "a.rs", "content A");
492        tax.ingest("dev", "rust", "b.rs", "content B");
493        let id_a = tax.drawers_in_scope("dev", "rust")[0].id.clone();
494        let id_b = tax.drawers_in_scope("dev", "rust")[1].id.clone();
495
496        // Link A -> B
497        tax.find_drawer_mut(&id_a).unwrap().link_to(&id_b);
498        let linked = tax.linked_active_ids(&id_a);
499        assert_eq!(linked, vec![id_b.clone()]);
500
501        // Supersede B, then linked should be empty
502        tax.find_drawer_mut(&id_b).unwrap().supersede("replacement");
503        assert!(tax.linked_active_ids(&id_a).is_empty());
504    }
505
506    #[test]
507    fn memory_taxonomy_add_closet() {
508        let mut tax = MemoryTaxonomy::new();
509        tax.add_closet("dev", "rust", Closet {
510            topic: "ownership".into(),
511            entities: vec!["borrow checker".into()],
512            drawer_ids: vec!["d1".into()],
513        });
514        let wing = tax.wings.get("dev").unwrap();
515        let room = wing.rooms.get("rust").unwrap();
516        assert_eq!(room.closets.len(), 1);
517        assert_eq!(room.closets[0].topic, "ownership");
518    }
519
520    #[test]
521    fn memory_taxonomy_stats_empty() {
522        let tax = MemoryTaxonomy::new();
523        assert_eq!(tax.stats(), (0, 0, 0));
524    }
525
526    #[test]
527    fn memory_taxonomy_serialization_roundtrip() {
528        let mut tax = MemoryTaxonomy::new();
529        tax.ingest("dev", "rust", "main.rs", "hello world");
530        // Verify the drawers themselves serialize roundtrip
531        let drawer = &tax.drawers_in_scope("dev", "rust")[0];
532        let json = serde_json::to_string(drawer).unwrap();
533        let deserialized: Drawer = serde_json::from_str(&json).unwrap();
534        assert_eq!(deserialized.content, drawer.content);
535        assert_eq!(deserialized.wing, drawer.wing);
536    }
537}