Skip to main content

wm_memory/
associations.rs

1//! Cross-galaxy association links with typed edges and Hebbian dynamics.
2//!
3//! Associations connect memories across galaxies via weighted, typed edges.
4//! Stored in the Associations galaxy with a composite key (`source_id` + `target_id`).
5//! Link weights strengthen with co-activation (Hebbian learning) and decay
6//! over time when not re-activated.
7
8use chrono::{DateTime, Utc};
9use lmdb::{Cursor, Database, DatabaseFlags, Environment, Transaction, WriteFlags};
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12use wm_core::{CoreError, Galaxy, Result};
13
14/// Typed association between two memories.
15///
16/// Replaces the legacy `association_type: String` with a typed enum.
17/// The string field is kept for backward compatibility but auto-populated
18/// from the `LinkType::as_str()` method.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum LinkType {
22    /// General semantic relatedness
23    #[default]
24    Related,
25    /// Target extends/refines source
26    Extends,
27    /// Target contradicts source
28    Contradicts,
29    /// Target supersedes/replaces source
30    Supersedes,
31    /// Temporal sequence (source before target)
32    Temporal,
33    /// Causal relationship (source causes target)
34    Causal,
35    /// Cascade / chain reaction
36    Cascade,
37}
38
39impl LinkType {
40    /// All variants in canonical order.
41    #[must_use]
42    pub const fn all() -> &'static [Self] {
43        &[
44            Self::Related,
45            Self::Extends,
46            Self::Contradicts,
47            Self::Supersedes,
48            Self::Temporal,
49            Self::Causal,
50            Self::Cascade,
51        ]
52    }
53
54    /// String label for JSON / display / backward compat.
55    #[must_use]
56    pub const fn as_str(self) -> &'static str {
57        match self {
58            Self::Related => "related",
59            Self::Extends => "extends",
60            Self::Contradicts => "contradicts",
61            Self::Supersedes => "supersedes",
62            Self::Temporal => "temporal",
63            Self::Causal => "causal",
64            Self::Cascade => "cascade",
65        }
66    }
67
68    /// Parse a string into a LinkType, falling back to Related for unknown strings.
69    #[must_use]
70    pub fn from_str_lossy(s: &str) -> Self {
71        match s {
72            "related" => Self::Related,
73            "extends" => Self::Extends,
74            "contradicts" => Self::Contradicts,
75            "supersedes" => Self::Supersedes,
76            "temporal" => Self::Temporal,
77            "causal" => Self::Causal,
78            "cascade" => Self::Cascade,
79            _ => Self::Related,
80        }
81    }
82}
83
84/// An association between two memories.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct Association {
87    /// Source memory UUID
88    pub source: Uuid,
89    /// Target memory UUID
90    pub target: Uuid,
91    /// Legacy association type string (auto-populated from `link_type`)
92    #[serde(default = "default_association_type")]
93    pub association_type: String,
94    /// Weight / strength (0.0 to 1.0) — dynamic, Hebbian
95    pub weight: f32,
96    /// Creation timestamp
97    pub created_at: DateTime<Utc>,
98    // ── Phase 6.2: Typed links + Hebbian dynamics ────────────────────
99    /// Typed link kind
100    #[serde(default)]
101    pub link_type: LinkType,
102    /// Number of co-activations (Hebbian counter)
103    #[serde(default)]
104    pub co_activation_count: u32,
105    /// Last time this link was activated (for decay computation)
106    #[serde(default = "default_last_activated")]
107    pub last_activated_at: DateTime<Utc>,
108    /// Decay half-life in days for the link weight
109    #[serde(default = "default_link_half_life_days")]
110    pub decay_half_life_days: f32,
111}
112
113fn default_association_type() -> String {
114    "related".to_string()
115}
116
117fn default_last_activated() -> DateTime<Utc> {
118    Utc::now()
119}
120
121const fn default_link_half_life_days() -> f32 {
122    90.0
123}
124
125impl Association {
126    /// Create a new typed association.
127    #[must_use]
128    pub fn new(source: Uuid, target: Uuid, link_type: LinkType, weight: f32) -> Self {
129        let now = Utc::now();
130        Self {
131            source,
132            target,
133            association_type: link_type.as_str().to_string(),
134            weight: weight.clamp(0.0, 1.0),
135            created_at: now,
136            link_type,
137            co_activation_count: 0,
138            last_activated_at: now,
139            decay_half_life_days: default_link_half_life_days(),
140        }
141    }
142
143    /// Create a new association with a custom decay half-life.
144    #[must_use]
145    pub const fn with_half_life_days(mut self, days: f32) -> Self {
146        self.decay_half_life_days = days.max(1.0);
147        self
148    }
149
150    /// Hebbian co-activation — strengthens the link weight.
151    ///
152    /// Boosts `weight` by `0.1 * (1.0 - current_weight)` (diminishing returns),
153    /// increments `co_activation_count`, and updates `last_activated_at`.
154    pub fn activate(&mut self) {
155        let now = Utc::now();
156        self.co_activation_count += 1;
157        self.last_activated_at = now;
158
159        // Hebbian boost: diminishing returns as weight approaches 1.0
160        let boost = 0.1 * (1.0 - self.weight);
161        self.weight = (self.weight + boost).clamp(0.0, 1.0);
162    }
163
164    /// Apply exponential decay to link weight based on time since last activation.
165    ///
166    /// `weight *= 0.5 ^ (days_since_activation / decay_half_life_days)`
167    pub fn decay(&mut self, now: DateTime<Utc>) {
168        let days_since = ((now - self.last_activated_at).num_seconds() as f32) / 86_400.0;
169        if days_since <= 0.0 {
170            return;
171        }
172        let half_life = self.decay_half_life_days.max(1.0);
173        let factor = 0.5_f32.powf(days_since / half_life);
174        self.weight = (self.weight * factor).clamp(0.0, 1.0);
175    }
176
177    /// Whether this link has decayed below a pruning threshold.
178    #[must_use]
179    pub fn should_prune(&self, threshold: f32) -> bool {
180        self.weight < threshold
181    }
182
183    /// Encode the composite key: source(16) + target(16) = 32 bytes.
184    #[must_use]
185    pub fn encode_key(&self) -> Vec<u8> {
186        let mut key = Vec::with_capacity(32);
187        key.extend_from_slice(self.source.as_bytes());
188        key.extend_from_slice(self.target.as_bytes());
189        key
190    }
191
192    /// Encode a key from source and target UUIDs.
193    #[must_use]
194    pub fn encode_key_pair(source: Uuid, target: Uuid) -> Vec<u8> {
195        let mut key = Vec::with_capacity(32);
196        key.extend_from_slice(source.as_bytes());
197        key.extend_from_slice(target.as_bytes());
198        key
199    }
200}
201
202/// Manages cross-galaxy associations in the Associations galaxy.
203pub struct AssociationStore {
204    db: Database,
205}
206
207impl AssociationStore {
208    /// Open the association store from an LMDB environment.
209    pub fn open(env: &Environment) -> Result<Self> {
210        let db = env
211            .create_db(
212                Some(Galaxy::Associations.db_name()),
213                DatabaseFlags::default(),
214            )
215            .map_err(|e| CoreError::Memory(format!("LMDB create_db for associations: {e}")))?;
216        Ok(Self { db })
217    }
218
219    /// Open an existing association database without creating it. Read-only
220    /// server startup uses this path so a missing database is a visible
221    /// preservation failure rather than an implicit schema mutation.
222    pub fn open_readonly(env: &Environment) -> Result<Self> {
223        let db = env
224            .open_db(Some(Galaxy::Associations.db_name()))
225            .map_err(|e| {
226                CoreError::Memory(format!("LMDB open association database read-only: {e}"))
227            })?;
228        Ok(Self { db })
229    }
230
231    /// Create or update an association.
232    pub fn put(&self, env: &Environment, assoc: &Association) -> Result<()> {
233        let key = assoc.encode_key();
234        let val = rmp_serde::to_vec(assoc)
235            .map_err(|e| CoreError::Memory(format!("serialize association: {e}")))?;
236
237        let mut tx = env
238            .begin_rw_txn()
239            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn: {e}")))?;
240        tx.put(self.db, &key, &val, WriteFlags::default())
241            .map_err(|e| CoreError::Memory(format!("LMDB put association: {e}")))?;
242        tx.commit()
243            .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
244        Ok(())
245    }
246
247    /// Get an association between two specific memories.
248    pub fn get(
249        &self,
250        env: &Environment,
251        source: Uuid,
252        target: Uuid,
253    ) -> Result<Option<Association>> {
254        let key = Association::encode_key_pair(source, target);
255        let tx = env
256            .begin_ro_txn()
257            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn: {e}")))?;
258        match tx.get(self.db, &key) {
259            Ok(bytes) => {
260                let assoc: Association = rmp_serde::from_slice(bytes)
261                    .map_err(|e| CoreError::Memory(format!("deserialize association: {e}")))?;
262                tx.commit()
263                    .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
264                Ok(Some(assoc))
265            }
266            Err(lmdb::Error::NotFound) => {
267                tx.commit()
268                    .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
269                Ok(None)
270            }
271            Err(e) => Err(CoreError::Memory(format!("LMDB get association: {e}"))),
272        }
273    }
274
275    /// Delete an association.
276    pub fn delete(&self, env: &Environment, source: Uuid, target: Uuid) -> Result<bool> {
277        let key = Association::encode_key_pair(source, target);
278        let mut tx = env
279            .begin_rw_txn()
280            .map_err(|e| CoreError::Memory(format!("LMDB rw_txn: {e}")))?;
281        let exists = tx.get(self.db, &key).is_ok();
282        if exists {
283            tx.del(self.db, &key, None)
284                .map_err(|e| CoreError::Memory(format!("LMDB del association: {e}")))?;
285        }
286        tx.commit()
287            .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
288        Ok(exists)
289    }
290
291    /// Find all associations where the given UUID is the source.
292    pub fn find_from(&self, env: &Environment, source: Uuid) -> Result<Vec<Association>> {
293        let prefix = source.as_bytes();
294        let tx = env
295            .begin_ro_txn()
296            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn: {e}")))?;
297        let mut cursor = tx
298            .open_ro_cursor(self.db)
299            .map_err(|e| CoreError::Memory(format!("LMDB cursor: {e}")))?;
300
301        let mut results = Vec::new();
302        for (key, val) in cursor.iter() {
303            if key.len() >= 16 && &key[..16] == prefix {
304                if let Ok(assoc) = rmp_serde::from_slice::<Association>(val) {
305                    results.push(assoc);
306                }
307            }
308        }
309        drop(cursor);
310        tx.commit()
311            .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
312        Ok(results)
313    }
314
315    /// Find all associations where the given UUID is the target.
316    pub fn find_to(&self, env: &Environment, target: Uuid) -> Result<Vec<Association>> {
317        let suffix = target.as_bytes();
318        let tx = env
319            .begin_ro_txn()
320            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn: {e}")))?;
321        let mut cursor = tx
322            .open_ro_cursor(self.db)
323            .map_err(|e| CoreError::Memory(format!("LMDB cursor: {e}")))?;
324
325        let mut results = Vec::new();
326        for (key, val) in cursor.iter() {
327            if key.len() >= 32 && &key[16..32] == suffix {
328                if let Ok(assoc) = rmp_serde::from_slice::<Association>(val) {
329                    results.push(assoc);
330                }
331            }
332        }
333        drop(cursor);
334        tx.commit()
335            .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
336        Ok(results)
337    }
338
339    /// Traverse cross-galaxy associations starting from a seed memory ID (S9 traversal).
340    ///
341    /// Walks association edges (outgoing and incoming) with weight >= `min_weight`,
342    /// resolving each neighbor across galaxies via `store.find_across_galaxies()`.
343    pub fn traverse_cross_galaxy(
344        &self,
345        env: &Environment,
346        store: &crate::store::MemoryStore,
347        seed_id: Uuid,
348        min_weight: f32,
349    ) -> Result<Vec<(Association, Galaxy, crate::memory::Memory)>> {
350        let outgoing = self.find_from(env, seed_id)?;
351        let incoming = self.find_to(env, seed_id)?;
352        let mut results = Vec::new();
353
354        for edge in outgoing.into_iter().chain(incoming) {
355            if edge.weight < min_weight {
356                continue;
357            }
358            let neighbor_id = if edge.source == seed_id {
359                edge.target
360            } else {
361                edge.source
362            };
363            if neighbor_id == seed_id {
364                continue;
365            }
366            if let Some((galaxy, mem)) = store.find_across_galaxies(neighbor_id)? {
367                results.push((edge, galaxy, mem));
368            }
369        }
370
371        Ok(results)
372    }
373
374    /// Count all associations.
375    pub fn count(&self, env: &Environment) -> Result<usize> {
376        let tx = env
377            .begin_ro_txn()
378            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn: {e}")))?;
379        let mut cursor = tx
380            .open_ro_cursor(self.db)
381            .map_err(|e| CoreError::Memory(format!("LMDB cursor: {e}")))?;
382        let count = cursor.iter().count();
383        drop(cursor);
384        tx.commit()
385            .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
386        Ok(count)
387    }
388
389    /// Find all direct circular associations (A→B and B→A both exist).
390    ///
391    /// Circular links can artificially inflate importance in retention
392    /// calculations. This method scans all associations and returns pairs
393    /// of UUIDs where both directions exist, allowing the retention engine
394    /// to avoid double-counting.
395    pub fn find_cycles(&self, env: &Environment) -> Result<Vec<(Uuid, Uuid)>> {
396        let tx = env
397            .begin_ro_txn()
398            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn: {e}")))?;
399        let mut cursor = tx
400            .open_ro_cursor(self.db)
401            .map_err(|e| CoreError::Memory(format!("LMDB cursor: {e}")))?;
402
403        // Collect all (source, target) pairs
404        let mut pairs: Vec<(Uuid, Uuid)> = Vec::new();
405        let mut seen: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new();
406        for (key, _val) in cursor.iter() {
407            if key.len() == 32 {
408                let mut arr = [0u8; 32];
409                arr.copy_from_slice(key);
410                if seen.insert(arr) {
411                    let src = Uuid::from_bytes(arr[..16].try_into().unwrap());
412                    let tgt = Uuid::from_bytes(arr[16..32].try_into().unwrap());
413                    pairs.push((src, tgt));
414                }
415            }
416        }
417        drop(cursor);
418        tx.commit()
419            .map_err(|e| CoreError::Memory(format!("LMDB commit: {e}")))?;
420
421        // Find cycles: for each (A, B), check if (B, A) also exists
422        let pair_set: std::collections::HashSet<[u8; 32]> = pairs
423            .iter()
424            .map(|(s, t)| {
425                let mut arr = [0u8; 32];
426                arr[..16].copy_from_slice(s.as_bytes());
427                arr[16..32].copy_from_slice(t.as_bytes());
428                arr
429            })
430            .collect();
431
432        let cycles: Vec<(Uuid, Uuid)> = pairs
433            .into_iter()
434            .filter(|(s, t)| {
435                // Check if reverse (t, s) exists, but avoid reporting both directions
436                let mut reverse = [0u8; 32];
437                reverse[..16].copy_from_slice(t.as_bytes());
438                reverse[16..32].copy_from_slice(s.as_bytes());
439                pair_set.contains(&reverse) && s < t // only report once per pair
440            })
441            .collect();
442
443        Ok(cycles)
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use tempfile::tempdir;
451
452    fn open_store() -> (tempfile::TempDir, Environment, AssociationStore) {
453        let tmp = tempdir().unwrap();
454        let env = Environment::new()
455            .set_map_size(1024 * 1024)
456            .set_max_dbs(16)
457            .open(tmp.path())
458            .unwrap();
459        let store = AssociationStore::open(&env).unwrap();
460        (tmp, env, store)
461    }
462
463    #[test]
464    fn put_and_get_association() {
465        let (_tmp, env, store) = open_store();
466        let src = Uuid::new_v4();
467        let tgt = Uuid::new_v4();
468        let assoc = Association::new(src, tgt, LinkType::Related, 0.8);
469        store.put(&env, &assoc).unwrap();
470
471        let got = store.get(&env, src, tgt).unwrap();
472        assert!(got.is_some());
473        let got = got.unwrap();
474        assert_eq!(got.association_type, "related");
475        assert_eq!(got.link_type, LinkType::Related);
476    }
477
478    #[test]
479    fn find_from_and_to() {
480        let (_tmp, env, store) = open_store();
481        let a = Uuid::new_v4();
482        let b = Uuid::new_v4();
483        let c = Uuid::new_v4();
484
485        store
486            .put(&env, &Association::new(a, b, LinkType::Related, 0.5))
487            .unwrap();
488        store
489            .put(&env, &Association::new(a, c, LinkType::Causal, 0.7))
490            .unwrap();
491        store
492            .put(&env, &Association::new(b, c, LinkType::Related, 0.3))
493            .unwrap();
494
495        let from_a = store.find_from(&env, a).unwrap();
496        assert_eq!(from_a.len(), 2);
497
498        let to_c = store.find_to(&env, c).unwrap();
499        assert_eq!(to_c.len(), 2);
500    }
501
502    #[test]
503    fn delete_association() {
504        let (_tmp, env, store) = open_store();
505        let src = Uuid::new_v4();
506        let tgt = Uuid::new_v4();
507        store
508            .put(&env, &Association::new(src, tgt, LinkType::Related, 0.5))
509            .unwrap();
510        assert!(store.delete(&env, src, tgt).unwrap());
511        assert!(store.get(&env, src, tgt).unwrap().is_none());
512    }
513
514    #[test]
515    fn count_associations() {
516        let (_tmp, env, store) = open_store();
517        assert_eq!(store.count(&env).unwrap(), 0);
518        store
519            .put(
520                &env,
521                &Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.5),
522            )
523            .unwrap();
524        store
525            .put(
526                &env,
527                &Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.5),
528            )
529            .unwrap();
530        assert_eq!(store.count(&env).unwrap(), 2);
531    }
532
533    // ── Phase 6.2: LinkType + Hebbian tests ────────────────────────────
534
535    #[test]
536    fn link_type_default_is_related() {
537        assert_eq!(LinkType::default(), LinkType::Related);
538    }
539
540    #[test]
541    fn link_type_all_has_7_variants() {
542        assert_eq!(LinkType::all().len(), 7);
543    }
544
545    #[test]
546    fn link_type_as_str_matches_expected() {
547        assert_eq!(LinkType::Related.as_str(), "related");
548        assert_eq!(LinkType::Extends.as_str(), "extends");
549        assert_eq!(LinkType::Contradicts.as_str(), "contradicts");
550        assert_eq!(LinkType::Supersedes.as_str(), "supersedes");
551        assert_eq!(LinkType::Temporal.as_str(), "temporal");
552        assert_eq!(LinkType::Causal.as_str(), "causal");
553        assert_eq!(LinkType::Cascade.as_str(), "cascade");
554    }
555
556    #[test]
557    fn link_type_from_str_lossy_roundtrip() {
558        for &lt in LinkType::all() {
559            assert_eq!(LinkType::from_str_lossy(lt.as_str()), lt);
560        }
561        assert_eq!(LinkType::from_str_lossy("unknown"), LinkType::Related);
562    }
563
564    #[test]
565    fn link_type_serde_roundtrip() {
566        for &lt in LinkType::all() {
567            let json = serde_json::to_string(&lt).unwrap();
568            let back: LinkType = serde_json::from_str(&json).unwrap();
569            assert_eq!(lt, back);
570        }
571    }
572
573    #[test]
574    fn new_association_populates_association_type_from_link_type() {
575        let assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Causal, 0.5);
576        assert_eq!(assoc.association_type, "causal");
577        assert_eq!(assoc.link_type, LinkType::Causal);
578    }
579
580    #[test]
581    fn new_association_has_hebbian_defaults() {
582        let assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.5);
583        assert_eq!(assoc.co_activation_count, 0);
584        assert!((assoc.decay_half_life_days - 90.0).abs() < f32::EPSILON);
585        // last_activated_at should be ~now
586        let now = Utc::now();
587        let diff = (now - assoc.last_activated_at).num_seconds().abs();
588        assert!(diff < 5);
589    }
590
591    #[test]
592    fn activate_boosts_weight() {
593        let mut assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.3);
594        let initial = assoc.weight;
595        assoc.activate();
596        assert!(assoc.weight > initial);
597        assert_eq!(assoc.co_activation_count, 1);
598    }
599
600    #[test]
601    fn activate_has_diminishing_returns() {
602        let mut assoc_high =
603            Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.9);
604        assoc_high.activate();
605        let boost_high = 0.1 * (1.0 - 0.9);
606        assert!((assoc_high.weight - (0.9 + boost_high)).abs() < 1e-5);
607
608        let mut assoc_low =
609            Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.1);
610        assoc_low.activate();
611        let boost_low = 0.1 * (1.0 - 0.1);
612        assert!((assoc_low.weight - (0.1 + boost_low)).abs() < 1e-5);
613    }
614
615    #[test]
616    fn activate_weight_caps_at_1() {
617        let mut assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.99);
618        for _ in 0..100 {
619            assoc.activate();
620        }
621        assert!(assoc.weight > 0.999);
622        assert!(assoc.weight <= 1.0);
623    }
624
625    #[test]
626    fn decay_reduces_weight_over_time() {
627        let mut assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 1.0);
628        assoc.decay_half_life_days = 30.0;
629        assoc.last_activated_at = Utc::now() - chrono::Duration::days(30);
630
631        assoc.decay(Utc::now());
632        // After one half-life, weight should be ~0.5
633        assert!((assoc.weight - 0.5).abs() < 0.01);
634    }
635
636    #[test]
637    fn decay_zero_time_is_noop() {
638        let mut assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.7);
639        let w = assoc.weight;
640        assoc.decay(Utc::now());
641        assert!((assoc.weight - w).abs() < f32::EPSILON);
642    }
643
644    #[test]
645    fn decay_uses_per_link_half_life() {
646        let mut short = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 1.0);
647        short.decay_half_life_days = 7.0;
648        short.last_activated_at = Utc::now() - chrono::Duration::days(7);
649
650        let mut long = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 1.0);
651        long.decay_half_life_days = 365.0;
652        long.last_activated_at = Utc::now() - chrono::Duration::days(7);
653
654        short.decay(Utc::now());
655        long.decay(Utc::now());
656        assert!(short.weight < long.weight);
657    }
658
659    #[test]
660    fn should_prune_detects_low_weight() {
661        let assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.05);
662        assert!(assoc.should_prune(0.1));
663        assert!(!assoc.should_prune(0.01));
664    }
665
666    #[test]
667    fn with_half_life_days_clamps_to_min_1() {
668        let assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Related, 0.5)
669            .with_half_life_days(0.1);
670        assert!((assoc.decay_half_life_days - 1.0).abs() < f32::EPSILON);
671    }
672
673    #[test]
674    fn serde_backward_compat_missing_hebbian_fields() {
675        // Old-format association without Phase 6.2 fields
676        let old_json = serde_json::json!({
677            "source": Uuid::new_v4().to_string(),
678            "target": Uuid::new_v4().to_string(),
679            "association_type": "related",
680            "weight": 0.5,
681            "created_at": "2025-01-01T00:00:00Z",
682        });
683
684        let assoc: Association = serde_json::from_value(old_json).unwrap();
685        assert_eq!(assoc.link_type, LinkType::Related);
686        assert_eq!(assoc.co_activation_count, 0);
687        assert!((assoc.decay_half_life_days - 90.0).abs() < f32::EPSILON);
688    }
689
690    #[test]
691    fn msgpack_roundtrip_preserves_hebbian_fields() {
692        let mut assoc = Association::new(Uuid::new_v4(), Uuid::new_v4(), LinkType::Causal, 0.6)
693            .with_half_life_days(14.0);
694        assoc.activate();
695        assoc.activate();
696
697        let bytes = rmp_serde::to_vec(&assoc).unwrap();
698        let back: Association = rmp_serde::from_slice(&bytes).unwrap();
699
700        assert_eq!(back.link_type, LinkType::Causal);
701        assert_eq!(back.association_type, "causal");
702        assert!((back.weight - assoc.weight).abs() < 1e-5);
703        assert_eq!(back.co_activation_count, 2);
704        assert!((back.decay_half_life_days - 14.0).abs() < f32::EPSILON);
705    }
706
707    #[test]
708    fn lmdb_roundtrip_preserves_hebbian_fields() {
709        let (_tmp, env, store) = open_store();
710        let src = Uuid::new_v4();
711        let tgt = Uuid::new_v4();
712
713        let mut assoc =
714            Association::new(src, tgt, LinkType::Contradicts, 0.4).with_half_life_days(7.0);
715        assoc.activate();
716
717        store.put(&env, &assoc).unwrap();
718        let back = store.get(&env, src, tgt).unwrap().unwrap();
719
720        assert_eq!(back.link_type, LinkType::Contradicts);
721        assert_eq!(back.association_type, "contradicts");
722        assert_eq!(back.co_activation_count, 1);
723        assert!((back.decay_half_life_days - 7.0).abs() < f32::EPSILON);
724    }
725
726    #[test]
727    fn find_cycles_detects_circular_links() {
728        let (_tmp, env, store) = open_store();
729        let a = Uuid::new_v4();
730        let b = Uuid::new_v4();
731
732        // Create circular: A→B and B→A
733        store
734            .put(&env, &Association::new(a, b, LinkType::Related, 1.0))
735            .unwrap();
736        store
737            .put(&env, &Association::new(b, a, LinkType::Related, 1.0))
738            .unwrap();
739
740        let cycles = store.find_cycles(&env).unwrap();
741        assert_eq!(cycles.len(), 1, "Should detect one cycle pair");
742        let (s, t) = cycles[0];
743        // The pair should be (min(a,b), max(a,b)) due to s < t filter
744        assert!(s < t);
745        assert!((s == a && t == b) || (s == b && t == a));
746    }
747
748    #[test]
749    fn find_cycles_no_cycles_when_unidirectional() {
750        let (_tmp, env, store) = open_store();
751        let a = Uuid::new_v4();
752        let b = Uuid::new_v4();
753        let c = Uuid::new_v4();
754
755        // Only unidirectional links — no cycles
756        store
757            .put(&env, &Association::new(a, b, LinkType::Related, 0.5))
758            .unwrap();
759        store
760            .put(&env, &Association::new(b, c, LinkType::Causal, 0.7))
761            .unwrap();
762        store
763            .put(&env, &Association::new(a, c, LinkType::Extends, 0.3))
764            .unwrap();
765
766        let cycles = store.find_cycles(&env).unwrap();
767        assert!(
768            cycles.is_empty(),
769            "Unidirectional links should not be cycles"
770        );
771    }
772
773    #[test]
774    fn find_cycles_detects_multiple_cycles() {
775        let (_tmp, env, store) = open_store();
776        let a = Uuid::new_v4();
777        let b = Uuid::new_v4();
778        let c = Uuid::new_v4();
779        let d = Uuid::new_v4();
780
781        // Two separate cycles: A↔B and C↔D
782        store
783            .put(&env, &Association::new(a, b, LinkType::Related, 0.5))
784            .unwrap();
785        store
786            .put(&env, &Association::new(b, a, LinkType::Related, 0.5))
787            .unwrap();
788        store
789            .put(&env, &Association::new(c, d, LinkType::Related, 0.5))
790            .unwrap();
791        store
792            .put(&env, &Association::new(d, c, LinkType::Related, 0.5))
793            .unwrap();
794
795        let cycles = store.find_cycles(&env).unwrap();
796        assert_eq!(cycles.len(), 2, "Should detect two cycle pairs");
797    }
798
799    #[test]
800    fn find_cycles_empty_store() {
801        let (_tmp, env, store) = open_store();
802        let cycles = store.find_cycles(&env).unwrap();
803        assert!(cycles.is_empty());
804    }
805}